The image and its secrets
The Dockerfile
Section titled “The Dockerfile”output: "standalone" makes Next.js copy only what the server needs into
.next/standalone, with a minimal server.js. Static assets aren’t included,
so they’re copied separately.
FROM node:26.13.0-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
FROM node:26.13.0-alpine AS buildWORKDIR /appENV NEXT_TELEMETRY_DISABLED=1ARG NEXT_PUBLIC_TURNSTILE_SITE_KEYENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEYCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
FROM node:26.13.0-alpine AS runWORKDIR /appENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0COPY --from=build --chown=node:node /app/.next/standalone ./COPY --from=build --chown=node:node /app/.next/static ./.next/staticUSER nodeEXPOSE 3000HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD wget -q --spider http://127.0.0.1:3000/standard || exit 1CMD ["node", "server.js"]The build needs the network: next/font downloads fonts and the npm section
fetches versions (with a fallback).
.dockerignore — before the first build
Section titled “.dockerignore — before the first build”Without it, COPY . . would bake .env.local, and any real keys in it, into
the image:
.env*!.env.examplenode_modules.next.gittest-resultsplaywright-reporte2edeployBuild for the server’s CPU
Section titled “Build for the server’s CPU”An image built on an Apple Silicon laptop is arm64; the VPS is x86_64. Build
on the server, in CI on the server, or with --platform linux/amd64. Until CI
existed, the repo was sent to the server with git archive (only committed
files, so no local .env) and built there:
git archive --format=tar HEAD | ssh <server> 'mkdir -p ~/apps/site && tar -x -C ~/apps/site'ssh <server> 'cd ~/apps/site && docker build --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=<site key> -t site:<commit> .'Check the real site key made it in, and the test key didn’t:
docker run --rm --entrypoint sh site:<commit> -c 'grep -rl <site key> .next | wc -l; grep -rl 1x00000000000000000000AA .next | wc -l'Secrets as files
Section titled “Secrets as files”The infra convention: every secret is a file in starter/secrets/, mode 600,
mounted at /run/secrets/<name>, and never an env var on disk.
cd /opt/vps-infra/starterread -rsp 'Resend API key: ' T && printf '%s' "$T" > secrets/resend_api_key.txt && unset T; echochmod 600 secrets/resend_api_key.txtwc -c secrets/resend_api_key.txt # length only, as confirmationThe files are owned by uid 1000 with mode 600, and the container’s node user
is also uid 1000, so it can read them.