node:22-bookworm for builds and node:22-bookworm-slim or gcr.io/distroless/nodejs22-debian13:nonroot for the runner. Node 18 is end of life; Node 20 is the previous LTS. Run as non-root, do not bake secrets into the image, and screen requests in the app.How do you self-host Next.js in Docker securely?
Use Node 22 LTS on Debian Bookworm, build with output: "standalone", run as a non-root user, and inject secrets at start from a manager. Do not use Node 18 (end of life). Node 20 is the previous LTS; prefer 22 for new images.
Application-layer checks still apply. Complete the Next.js security checklist before you harden the image. Structured logs belong in the same image; see structured logging in Next.js.
What is the self-hosted Next.js Docker checklist?
- Pin
node:22-bookworm(or a digest) for build stages andnode:22-bookworm-slimorgcr.io/distroless/nodejs22-debian13:nonrootfor the runner. - Set
output: "standalone"innext.configand copy only the standalone trace, static assets, and public files. - Create a dedicated user and set
USERbeforeCMD. - Do not bake production secrets into the image or into a committed
.env. - Drop the shell in production when you can; use Distroless or a slim image.
- Keep the host patched, expose only the app port, and terminate TLS in front of the container.
- Scan the image (Trivy or equivalent) in CI on every build.
Which container image should you use?
The official Next.js Docker example has used Alpine and older Node lines. Alpine's musl libc breaks some native addons if you develop on glibc. Debian Bookworm matches most developer machines.
| Stage | Image | Why |
|---|---|---|
| Build | node:22-bookworm | Full toolchain, glibc, current Active LTS |
| Runner (shell available) | node:22-bookworm-slim | Smaller attack surface; enough to run |
| Runner (no shell) | gcr.io/distroless/nodejs22-debian13:nonroot | No package manager, no shell, non-root user |
Pin a minor or a digest (node:22.18-bookworm) so a rebuild does not surprise you. Update that pin on a schedule. Avoid node:lts; the major version changes under you.
Why run Next.js as a non-root user?
If the Node process is root, a remote code execution bug can install packages, read every file in the container, and attempt a container escape with extra privileges. Create a user, chown the app files, and set USER before CMD.
RUN groupadd -r nodejs && useradd -r -g nodejs -d /app -s /sbin/nologin nextjs \ && chown -R nextjs:nodejs /app
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjsCMD ["node", "server.js"]Distroless nonroot images already provide a non-root UID. Use COPY --chown=nonroot:nonroot and USER nonroot.
How should Dockerized Next.js load secrets?
Do not copy a production .env with raw keys into the image. Anyone with the image can docker history or extract the layer. Do not rely on docker run -e for long-lived production secrets either; they appear in process listings and crash dumps.
| Manager | Use when | Trade-off |
|---|---|---|
| 1Password | The team already stores secrets there; the CLI can inject at start | A scoped service-account token still lives in the environment; the CLI needs a shell and CA certificates, so Distroless is a poor fit |
| HashiCorp Vault | You want a dedicated control plane, dynamic credentials, and audit | You operate Vault (or HCP) and an agent or SDK in the process |
| AWS Secrets Manager | The app runs on ECS, EKS, or Lambda with IAM roles | AWS-specific; pair with IAM and, on EKS, the Secrets Store CSI driver |
A 1Password pattern that keeps values out of the image:
COPY --chown=nextjs:nodejs --from=1password/op:2 /usr/local/bin/op /usr/local/bin/opUSER nextjsCMD ["/usr/local/bin/op", "run", "--env-file=/app/.env.production", "--", "node", "server.js"].env.production holds references such as ARCJET_KEY=op://app.arcjet.com/ARCJET_KEY/credential, not the key itself. The only long-lived secret in the container is OP_SERVICE_ACCOUNT_TOKEN, scoped to one vault.
On Distroless, skip the CLI. Use the manager's SDK or the platform's native injection (ECS secrets, Kubernetes secret volumes).
What does a Node 22 standalone Dockerfile look like?
FROM node:22-bookworm AS installerWORKDIR /appENV NEXT_TELEMETRY_DISABLED=1COPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:22-bookworm-slim AS runnerWORKDIR /appRUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && groupadd -r nodejs \ && useradd -r -g nodejs -d /app -s /sbin/nologin nextjs \ && chown -R nextjs:nodejs /app
COPY --from=installer --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=installer --chown=nextjs:nodejs /app/.next/static ./.next/staticCOPY --from=installer --chown=nextjs:nodejs /app/public ./public
USER nextjsENV NODE_ENV=productionENV NEXT_TELEMETRY_DISABLED=1EXPOSE 3000CMD ["node", "server.js"]Set output: "standalone" in next.config.ts. Adjust static and public copy paths if the app lives in a monorepo subdirectory.
Distroless runner (no op CLI):
FROM gcr.io/distroless/nodejs22-debian13:nonroot AS runnerWORKDIR /appCOPY --from=installer --chown=nonroot:nonroot /app/.next/standalone ./COPY --from=installer --chown=nonroot:nonroot /app/.next/static ./.next/staticCOPY --from=installer --chown=nonroot:nonroot /app/public ./publicUSER nonrootENV NODE_ENV=productionEXPOSE 3000CMD ["server.js"]The Distroless entrypoint is node, so CMD is the script path only.
How do you screen requests in a self-hosted container?
Hosting on your VM does not replace application-layer limits. Add the same v1 rules you would use on Vercel:
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: ["CATEGORY:SEARCH_ENGINE"] }), slidingWindow({ mode: "LIVE", interval: 60, max: 100 }), ],});
export async function POST(req: Request) { const decision = await aj.protect(req); if (decision.isDenied()) { return Response.json({ error: "Forbidden" }, { status: 403 }); }}If a reverse proxy sits in front, configure proxies so Arcjet sees the client IP, not the proxy. Emit JSON logs from the same process so denials are searchable; the structured logging guide shows the Instrumentation hook.
What else does the host need?
Patch the VM. Expose only 80 and 443 on the public interface, or nothing public if you reach the box over a mesh. Run a reverse proxy with TLS. Do not publish Docker's socket. Scan images in CI and rebuild when Node or Debian publishes a security update.
Add a health check that hits a cheap route, not the homepage if the homepage runs expensive queries. Do not run docker.sock into the app container. Publish only port 3000 on a private network and terminate TLS on the proxy.
Rebuild when Node publishes a security release on the 22 line, even if your app code did not change. Distroless images lag the official Node tag by a short window; pin and update both.
A locked-down image with a world-open login route is still an open login route. Keep the Next.js security checklist in the same review as the Dockerfile.
Frequently asked questions
Which Node version should a Next.js Docker image use?
Node 22 LTS. Node 18 is end of life. Node 20 is the previous LTS. Pin node:22-bookworm (or a digest), not node:lts.
Should you use Alpine or Debian for Next.js containers?
Prefer Debian Bookworm. Alpine's musl libc breaks some native addons if you develop on glibc.
Can you use the 1Password CLI with Distroless?
Poorly. Distroless has no shell and few libraries the CLI needs. Use Distroless with platform or SDK injection, or use a slim image if you run op.
Why run the Next.js process as non-root?
A compromised root Node process can install packages, read every file in the container, and try a container escape with extra privileges.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.