Application & framework security

Security advice for self-hosting Next.js in Docker

Pin 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.

6 min read
In short: Pin 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 and node:22-bookworm-slim or gcr.io/distroless/nodejs22-debian13:nonroot for the runner.
  • Set output: "standalone" in next.config and copy only the standalone trace, static assets, and public files.
  • Create a dedicated user and set USER before CMD.
  • 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.

StageImageWhy
Buildnode:22-bookwormFull toolchain, glibc, current Active LTS
Runner (shell available)node:22-bookworm-slim

Smaller attack surface; enough to run node server.js

Runner (no shell)gcr.io/distroless/nodejs22-debian13:nonrootNo 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 nextjs
CMD ["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.

ManagerUse whenTrade-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 ManagerThe 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/op
USER nextjs
CMD ["/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 installer
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
RUN 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/static
COPY --from=installer --chown=nextjs:nodejs /app/public ./public
USER nextjs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["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 runner
WORKDIR /app
COPY --from=installer --chown=nonroot:nonroot /app/.next/standalone ./
COPY --from=installer --chown=nonroot:nonroot /app/.next/static ./.next/static
COPY --from=installer --chown=nonroot:nonroot /app/public ./public
USER nonroot
ENV NODE_ENV=production
EXPOSE 3000
CMD ["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.