build: package persistent deployment and maintenance commands

This commit is contained in:
2026-09-15 21:48:12 +03:30
parent 9319c05710
commit ee9f49e4b7
7 changed files with 59 additions and 12 deletions

View File

@@ -3,6 +3,10 @@
.next
out
node_modules
data
backups
test-results
playwright-report
.env
.env.*

View File

@@ -4,6 +4,9 @@ FROM node:22-alpine AS dependencies
WORKDIR /app
# npm may build the native SQLite binding when no matching prebuild is available.
RUN apk add --no-cache python3 make g++
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
ENV NEXT_TELEMETRY_DISABLED=1
@@ -29,7 +32,8 @@ WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000
PORT=3000 \
DATA_DIR=/data
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
@@ -37,12 +41,12 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=verification --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=verification --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=verification --chown=nextjs:nodejs /app/public ./public
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
RUN mkdir -p .next/cache /data && chown -R nextjs:nodejs .next/cache /data
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=2s --timeout=2s --start-period=3s --retries=30 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "server.js"]

15
compose.selfhost.yaml Normal file
View File

@@ -0,0 +1,15 @@
services:
portfolio-app:
build: .
image: portfolio-builder:local
restart: unless-stopped
ports:
- "127.0.0.1:${PORT:-3000}:3000"
environment:
SITE_URL: ${SITE_URL:-http://localhost:3000}
DATA_DIR: /data
volumes:
- ${PORTFOLIO_DATA_DIR:-./data}:/data
- next-image-cache:/app/.next/cache
volumes:
next-image-cache:

View File

@@ -3,13 +3,16 @@ services:
image: ${PORTFOLIO_IMAGE:-amiirkhl-portfolio:latest}
container_name: portfolio-app
restart: unless-stopped
environment:
SITE_URL: ${SITE_URL:-https://portfolio.example.com}
DATA_DIR: /data
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
"fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 2s
timeout: 2s
@@ -17,6 +20,7 @@ services:
retries: 30
volumes:
- next-image-cache:/app/.next/cache
- portfolio-data:/data
networks:
- caddy_proxy
@@ -27,3 +31,4 @@ networks:
volumes:
next-image-cache:
portfolio-data:

View File

@@ -0,0 +1,2 @@
import { build } from 'esbuild';
await build({ entryPoints: ['scripts/maintenance.ts'], outfile: '.next/standalone/maintenance.mjs', bundle: true, platform: 'node', target: 'node22', format: 'esm', packages: 'external' });

View File

@@ -125,9 +125,14 @@ fi
compose() {
local directory="$1"
shift
local runtime_environment=()
if [[ -f "$deploy_root/runtime.env" ]]; then
runtime_environment=(--env-file "$deploy_root/runtime.env")
fi
docker compose \
--project-name portfolio \
--file "$directory/compose.yaml" \
"${runtime_environment[@]}" \
--env-file "$directory/release.env" \
"$@"
}
@@ -187,12 +192,7 @@ if activate "$release_dir"; then
exit 0
fi
echo "Deployment failed; attempting rollback." >&2
if [[ -n "$previous_release" && -f "$previous_release/compose.yaml" && -f "$previous_release/release.env" ]]; then
activate "$previous_release"
echo "Rollback succeeded: $(basename "$previous_release")" >&2
else
echo "No image-based previous release is available for automatic rollback." >&2
fi
echo "Deployment failed. Automatic image rollback is disabled because startup may have migrated the persistent database." >&2
echo "Inspect the failed container. Restore the pre-upgrade backup into a new data directory before activating an older image; see SELF_HOSTING.md." >&2
exit 1

View File

@@ -1,10 +1,27 @@
import { cp } from 'node:fs/promises';
import { cp, readdir, rm } from 'node:fs/promises';
import path from 'node:path';
const projectRoot = path.resolve('.');
const standaloneRoot = path.join(projectRoot, '.next', 'standalone');
// Instrumentation traces can bypass Next's route-level exclusions. Runtime
// state belongs to the mounted volume, never to a distributable build.
async function removeRuntimeState(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.resolve(directory, entry.name);
if (!target.startsWith(`${standaloneRoot}${path.sep}`)) throw new Error('Invalid package path');
if (entry.name === 'node_modules') continue;
if (entry.name === 'data' || entry.name === 'backups' || entry.name === 'auth-secret' || /\.sqlite(?:-|$)/.test(entry.name) || /^\.env(?:\.|$)/.test(entry.name)) {
await rm(target, { recursive: entry.isDirectory(), force: true });
} else if (entry.isDirectory()) {
await removeRuntimeState(target);
}
}
}
await removeRuntimeState(standaloneRoot);
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
await cp(path.join(projectRoot, 'migrations'), path.join(standaloneRoot, 'migrations'), { recursive: true });
await cp(
path.join(projectRoot, '.next', 'static'),
path.join(standaloneRoot, '.next', 'static'),