Shipping Next.js with Docker Without Leaking Secrets
A production-minded Docker setup for Next.js standalone — build args vs runtime env, .dockerignore, and why API keys should never bake into the image.
Nguyễn Đình Giang
· · 4 min read
Frontend developer in Ha Noi. I write about the Next.js, WordPress, and performance work I actually ship.
Docker makes deploys repeatable. It also makes it dangerously easy to bake API keys into an image you later push to a registry.
I’ve tightened this on my own portfolio: Next.js standalone, a multi-stage Dockerfile, Compose for runtime config, and a hard rule — secrets never enter the build stage.
The shape of the image
- Builder — install deps, run
next buildwithoutput: "standalone" - Runner — copy standalone output, static assets, and run as a non-root user
Only non-secret build config belongs in ARG / ENV during build. For me that’s things like APP_URL for absolute links and metadata — not Gmail, Resend, or OpenAI keys.
# Build stage — public config only
ARG APP_URL
ENV APP_URL=${APP_URL}
# Runner — inject secrets at container start, not bake time
# Compose: env_file: .envWhy build-time secrets are a trap
If you pass RESEND_API_KEY as a Docker ARG, it can end up in image history even if you “don’t use it” in the final stage. Anyone who can pull the image can inspect layers.
The fix is boring and correct:
- Put
.envin.dockerignore - Pass secrets only at runtime (
env_file/ orchestrator secrets) - Keep server keys off the
NEXT_PUBLIC_*prefix so they never ship to the browser
Compose responsibilities
services:
next-app:
build:
context: .
args:
APP_URL: ${APP_URL}
env_file:
- .envBuild gets the public URL. The running container gets email and AI keys. The published image stays reusable across environments.
Checklist before docker compose up --build
-
.envis gitignored and dockerignored - No secret
ARG/ENVin the Dockerfile - API routes read
process.envat request time - Old registry tags rebuilt after removing baked secrets
- Keys rotated if an old image was ever public
Using BuildKit secret mounts
Runtime env_file is the right default for this portfolio: the Next.js server reads process.env when a request hits an API route. It does not cover the other leak, which is a secret the build itself needs — a private npm token, a font license, a CMS token only next build uses.
Passing that as ARG still writes it into image history. BuildKit secret mounts keep the value in a tmpfs for one RUN and never commit it as a layer.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" \
corepack enable && pnpm install --frozen-lockfile
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG APP_URL
ENV APP_URL=${APP_URL}
RUN pnpm build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN useradd --create-home --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]Build it with the secret outside the image:
docker build \
--secret id=npm_token,env=NPM_TOKEN \
--build-arg APP_URL=https://rivernguyen.id.vn \
-t river-portfolio .APP_URL stays a build arg because it is public and Next.js inlines it into metadata. NPM_TOKEN is not an ARG. If next build needs a private registry, the mount belongs on the install RUN only. API keys for email or the model stay in Compose env_file and are read at request time.
What docker history should show
After a bad build, history is the proof, not the Dockerfile you wish you had written. A leaked key looks like this (sample, not a real token):
docker history river-portfolio:leaked
IMAGE CREATED BY
... ENV RESEND_API_KEY=re_sample_not_a_real_key
... ARG RESEND_API_KEYA clean image does not contain that line. I check three commands before a tag is pushed:
docker history --no-trunc river-portfolio:latest | grep -Ei 'key|token|secret|password' || echo "no secret strings in history"
docker image inspect river-portfolio:latest --format '{{json .Config.Env}}'
trufflehog docker --image river-portfolio:latestgrep is a smoke test. It misses base64 and values that only exist inside a node_modules tarball copied forward. The secret mount avoids that copy. .dockerignore still has to exclude .env, .env.local, and any *.pem so COPY . . cannot sneak them into the builder context.
Rotation runbook
If an old tag was public, or history shows a key, rotating the app without rotating the key does nothing. The order I use:
- Revoke the key at the provider (Resend, OpenAI, npm). Do this before the rebuild so a pulled old image is already dead.
- Issue a new key into the runtime secret store only — host
.envfor Compose, or the orchestrator secret. Not a build arg. - Rebuild from a Dockerfile that has no secret
ARG/ENV, with--no-cacheon the stage that used to copy the leak. - Retag and delete the bad digest in the registry. Deleting a tag is not enough if the digest is still pullable.
- Confirm
docker historyon the new tag, then redeploy. Server routes should fail closed if the new env var is missing, not fall back to a baked default.
I treat the image as something I might push to the wrong registry. That is the same rule on client Dockerfiles and on this portfolio's standalone output.
On this site the production path is still Vercel-first, but the same Dockerfile is what I use for local parity and for clients who want Compose. The rule does not change with the host: public config may bake; secrets may not.
The booking builds that sit next to this setup are written up as case studies, starting with the multilingual tour platform. Docker docs for the mount syntax live at Build secrets. Next.js standalone output is documented under output: standalone.
Final thought
Docker isn’t just packaging — it’s a security boundary. Treat the image as something you might accidentally publish. If a key would embarrass you in a registry dump, it doesn’t belong in the build.
Need this kind of feature on your site? See hire details →
Get new posts by email
One email when I publish something new. No spam, unsubscribe anytime.

