diff --git a/.dockerignore b/.dockerignore index 73c5372..935331f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,10 @@ .next out node_modules +data +backups +test-results +playwright-report .env .env.* diff --git a/Dockerfile b/Dockerfile index 8ffd02b..42b8c31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/compose.selfhost.yaml b/compose.selfhost.yaml new file mode 100644 index 0000000..572573f --- /dev/null +++ b/compose.selfhost.yaml @@ -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: diff --git a/compose.yaml b/compose.yaml index 0cdd29a..62a120d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/scripts/build-maintenance.mjs b/scripts/build-maintenance.mjs new file mode 100644 index 0000000..fc76846 --- /dev/null +++ b/scripts/build-maintenance.mjs @@ -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' }); diff --git a/scripts/deploy-release.sh b/scripts/deploy-release.sh index ea12c31..7a2f229 100644 --- a/scripts/deploy-release.sh +++ b/scripts/deploy-release.sh @@ -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 diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs index 5fee08f..48bab69 100644 --- a/scripts/prepare-standalone.mjs +++ b/scripts/prepare-standalone.mjs @@ -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'),