Next.js
Updated
8 min read

Security advice for self-hosting Next.js in Docker

Learn how to improve the security of your self-hosted Next.js applications. This guide covers Docker container image best practices, secret management, vulnerability mitigation, and more, so your Next.js projects are better protected from threats.

Security advice for self-hosting Next.js in Docker

Self-hosting a Next.js application offers flexibility and control, but it also comes with security challenges. If you've decided to move beyond platforms like Vercel or Netlify, you're now responsible for everything from container image security to secret management.

Next.js has gained a reputation for being tricky to self-host, but recent updates have simplified containerization. That's great news for deployment, but it doesn't solve your security concerns.

Which container image should you choose, and how will you track security updates? What about the underlying platform? Host OS vulnerabilities? How do you handle environment variables and secrets? What are your firewall rules?

At Arcjet, we’re building an SDK to help developers protect their apps. We integrate with modern platforms like Vercel, but our rate limiting, bot detection, email validation and attack detection functionality works wherever you’re hosting your application (such as Coolify).

In this post, we'll tackle the most critical security considerations when self-hosting Next.js applications.

Next.js security checklist

Before working on the infrastructure layer there are a few key things to get right in your application first. These include managing dependencies, data validation, avoiding code exposure, security headers, and centralization of security functionality.

These are all covered in our Next.js security checklist, so I won’t go through them again here.

Crafting a Secure Next.js Container Image

Choosing the right container image is important for your Next.js application's security. While the official Next.js Docker example provides a starting point, there are optimizations to consider.

Selecting the base image

The official example uses Node 18 on Alpine Linux, a popular choice for its small size. However, Alpine's musl libc can lead to compatibility issues with certain libraries, especially if you're not developing and testing against musl locally.

A safer option is to use a standard Node image based on Debian, which is more widely compatible and less likely to cause unexpected issues. See this discussion for the pros/cons.

Node Version and image variants

Node 18 is still in Long Term Support (LTS), but it's in maintenance-only mode. Consider upgrading to Node 20, the current Active LTS version, for active development and support. As per the docs:

Major Node.js versions enter Current release status for six months, which gives library authors time to add support for them. After six months, odd-numbered releases (9, 11, etc.) become unsupported, and even-numbered releases (10, 12, etc.) move to Active LTS status and are ready for general use. LTS release status is "long-term support", which typically guarantees that critical bugs will be fixed for a total of 30 months. Production applications should only use Active LTS or Maintenance LTS releases.

The Next.js Dockerfile example uses multi-stage builds to optimize caching and reduce the final image size.

The full version with all the build tools can be used for the earlier stages, but the final “runner” stage is where I would recommend using the “slim” image variant from the list of tags. The slim version "does not contain the common packages contained in the default tag and only contains the minimal packages needed to run node". Perfect for production.

Releases
Node.js release schedule (docs). Node 18 is in maintenance mode whereas Node 20 is active.
  • Build Stages (base and installer): node:20-bookworm (or pin to a specific Node version like node:20.15-bookworm)
  • Runner Stage: node:20-bookworm-slim (or pin to a specific Node version like node:20.15-bookworm-slim)

(A full Dockerfile is provided at the end of this post)

The base OS is Debian Bookworm, which is the latest release. If you use node:lts then you’ll also get Debian Bookworm. However, this will change over time so it’s good practice to pin specific versions. That way you don’t get sudden changes as new versions are released.

This combination offers a balance of compatibility, active support, and a reduced attack surface. Remember to regularly update the version numbers in your Dockerfile to stay current.

Running as a non-root user

Another important security practice is to avoid running your application as the root user, even within a container.

If you launch Next.js as root then any code it executes will have root permissions. This allows them to install other software, run other processes, and access all files within the container. This makes it easier to exploit a container escape vulnerability, read out your environment variables, or access other tools within the container.

If an attacker exploits a vulnerability, running as a non-root user limits the potential damage.

The official example demonstrates creating a dedicated nextjs user and nodejs group. Follow this practice in your Dockerfile, ensuring files are owned by this user and the main process runs as nextjs

RUN groupadd -r nodejs && useradd -r -g nodejs -d /app -s /sbin/nologin nextjs \
    && chown -R nextjs:nodejs /app

Dockerfile lines to add a new user and group.

Copy over the output traces when building Next.js in standalone mode and chown them to the nextjs:nodejs user and group:

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

Dockerfile lines to copy files in with the correct ownership.

Finally, you need to set the USER directive before CMD

USER nextjs

Dockerfile line to set the user that CMD will execute as.

A full Dockerfile is provided below.

Managing secrets in Next.js containers

Next.js doesn't natively offer robust secret management, but there are several strategies to ensure your sensitive information remains protected within a containerized environment.

As recommended in our Next.js security checklist, avoid storing secrets directly as environment variables. Instead, leverage a dedicated secret manager tool. This provides centralized control, audit trails, and easier rotation of credentials.

One option is to use dotenvx which allows you to encrypt secrets inside the .env file. An alternative is to use references stored in a tool like 1Password. By referencing secrets in a .env file and using a CLI to fetch them at launch, you avoid exposing secrets in your container's environment.

Alternatives include Hashicorp Vault Secrets, AWS Secrets Manager, Doppler, and Infisical. We’re using 1Password because we also use it for all our other company secrets.

Here I created a .env.production file with the secret names pointing to the 1Password reference:

ARCJET_KEY="op://app.arcjet.com/ARCJET_KEY/credential"

Example .env.production containing a 1Password reference for a secret.

The 1Password CLI requires that a service account token is set as OP_SERVICE_ACCOUNT_TOKEN which has limited access just to read values from a specific 1Password vault.

We bring the op CLI into the container with this COPY directive:

COPY --chown=nextjs:nodejs --from=1password/op:2 /usr/local/bin/op /usr/local/bin/op

Then the CMD to start the Next.js server is executed through the 1Password CLI run command (docs):

CMD [ 
  "/usr/local/bin/op", "run", "--env-file=/app/.env.production", 
  "--", "node", "/app/server.js" 
]

This eliminates all secrets from the environment variables, except for the service account token. Although this token could be used to access the secrets from 1Password, the token is scoped to a specific vault and you gain benefits such as:

  • No Exposed Secrets: Secrets never exist as plain text in your environment.
  • Auditing: Secrets managers offer detailed audit logs for tracking access.
  • Rotation: Easily rotate secrets without rebuilding your container image.

This is a major improvement over plain environment variables which have no logging, can’t be as easily rotated, and are not tracked anywhere.

Next.js Dockerfile example

This Dockerfile leverages Node 20 on Debian Bookworm to install project dependencies and build the Next.js application. It assumes you have set output: "standalone" in your next.config.js then copies the standalone output into a minimal runtime environment, executing as a non-root user for enhanced security. Secrets are managed externally using 1Password, referenced within a .env.production file.

FROM node:20.15-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn global add turbo@2

# Installer grabs the files we need, then installs the dependencies
FROM base AS installer
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
COPY . .
RUN npm ci
RUN turbo run build

# Runner copies everything from the installer, then runs the app as a locked
# down nextjs user
FROM node:20.15-bookworm-slim AS runner
WORKDIR /app
# ca-certificates is needed for 1Password CLI
RUN apt-get update && apt-get install -y ca-certificates --no-install-recommends && rm -rf /var/lib/apt/lists/*
RUN groupadd -r nodejs && useradd -r -g nodejs -d /app -s /sbin/nologin nextjs \
    && chown -R nextjs:nodejs /app

# Copy in the built files. Automatically leverage output traces to reduce image
# size: https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --chown=nextjs:nodejs --from=installer /app/.env.production .
COPY --chown=nextjs:nodejs --from=installer /app/next.config.mjs .
COPY --chown=nextjs:nodejs --from=installer /app/package.json .
COPY --chown=nextjs:nodejs --from=installer /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=installer /app/.next/static ./apps/app/.next/static
COPY --chown=nextjs:nodejs --from=installer /app/public ./apps/app/public

# Include the 1Password CLI tool, which we use to fetch secrets at runtime
COPY --chown=nextjs:nodejs --from=1password/op:2 /usr/local/bin/op /usr/local/bin/op

USER nextjs

ENV NODE_ENV="production"
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD [ "/usr/local/bin/op", "run", "--env-file=/app/.env.production", "--", "node", "/app/server.js" ]

Example Next.js Dockerfile using a minimal slim Node 20 Debian Bookworm image and the 1Password CLI to load secrets from a .env.production file.

You can build and run with the following commands:

docker build -t nextjstest1:latest .
docker run -it --rm --name nextjstest1 -p 3000:3000 -e OP_SERVICE_ACCOUNT_TOKEN=... nextjstest1:latest

If you get an error like this then check that your 1Password service token is correct:

[ERROR] error initializing client: Validation: (failed to 
session.DecodeSACredentials), Server: (failed to 
DecodeSACredentials), failed to parseToken, format is invalid

Do you even need a shell?

While convenient for debugging, including a shell in your production Next.js container introduces unnecessary risk. A shell provides an additional attack vector that malicious actors could potentially exploit.

Why Remove the Shell?

  • Reduced Attack Surface: Eliminating the shell removes a potential entry point for attackers.
  • Principle of Least Privilege: Adhering to this principle means providing only the minimal access necessary. If your application doesn't require a shell for its core functionality, removing it enhances security.

Alternative Approaches

  • Google Distroless Images: These images are designed for minimal attack surface, containing only the essential components needed to run your application. They also offer non-root versions, further enhancing security.
  • Dedicated Debugging Containers: Maintain separate container images for debugging, equipped with a shell and additional tools. This enables isolated troubleshooting without compromising your production environment.

Trade-offs

Removing the shell makes debugging within the production container more challenging. Further, using a Distroless container image is not compatible with the 1Password CLI to load secrets at runtime. The Distroless image removes some dependencies needed by the CLI, preventing it from running. 

This means you will either need to load secrets into environment variables or integrate a secrets manager SDK into your code. If you’re on AWS and using Kubernetes then you can use AWS Secrets Manager with EKS.

Distroless Dockerfile for Next.js

Distroless images contain no build tools by design so we still use the Node 20 Bookworm image as our base, but Distroless for the runner:

FROM node:20.15-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1

# Installer grabs the files we need, then installs the dependencies
FROM base AS installer
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

# Runner copies everything from the installer, then runs the app as a locked
# down nonroot user
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runner
WORKDIR /app

# Copy in the built files. Automatically leverage output traces to reduce image
# size: https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --chown=nonroot:nonroot --from=installer /app/.env.production .
COPY --chown=nonroot:nonroot --from=installer /app/next.config.mjs .
COPY --chown=nonroot:nonroot --from=installer /app/package.json .
COPY --chown=nonroot:nonroot --from=installer /app/.next/standalone ./
COPY --chown=nonroot:nonroot --from=installer /app/.next/static ./apps/app/.next/static
COPY --chown=nonroot:nonroot --from=installer /app/public ./apps/app/public

USER nonroot

ENV NODE_ENV="production"
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD [ "server.js" ]

Example Next.js Dockerfile using Distroless nodejs20-debian12:nonroot as the runner image.

Conclusion

Self-hosting Next.js gives you more flexibility and control, but it demands a proactive approach to security. By carefully selecting container images, managing secrets effectively, and minimizing the attack surface, you can ensure your Next.js applications are both performant and resilient against potential threats.

Remember, security is an ongoing process. Stay informed about the latest vulnerabilities, regularly update your dependencies, and adopt a defense-in-depth strategy to protect your Next.js projects.

Related articles

Subscribe by email

Get the full posts by email every week.