Skip to content

The image and its secrets

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 deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:26.13.0-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:26.13.0-alpine AS run
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q --spider http://127.0.0.1:3000/standard || exit 1
CMD ["node", "server.js"]

The build needs the network: next/font downloads fonts and the npm section fetches versions (with a fallback).

Without it, COPY . . would bake .env.local, and any real keys in it, into the image:

.env*
!.env.example
node_modules
.next
.git
test-results
playwright-report
e2e
deploy

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:

Terminal window
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:

Terminal window
docker run --rm --entrypoint sh site:<commit> -c 'grep -rl <site key> .next | wc -l; grep -rl 1x00000000000000000000AA .next | wc -l'

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.

Terminal window
cd /opt/vps-infra/starter
read -rsp 'Resend API key: ' T && printf '%s' "$T" > secrets/resend_api_key.txt && unset T; echo
chmod 600 secrets/resend_api_key.txt
wc -c secrets/resend_api_key.txt # length only, as confirmation

The files are owned by uid 1000 with mode 600, and the container’s node user is also uid 1000, so it can read them.