Docker and Next.js fit together in three steps: set output: 'standalone' in next.config.js so the build emits a self-contained server with only the dependencies it uses, write a multi-stage Dockerfile that installs, builds, and then copies just the standalone output into a small runtime image, and understand that NEXT_PUBLIC_ variables are baked in at build time while everything else is read at run time. Skip the first step and the image is a gigabyte. Skip the third and the app cannot find its API URL in production.
The official container guide, the framework’s example Dockerfile and a hundred blog posts agree on the shape of the file. What they cover less well is the failure that hits nearly every first attempt: the app builds, the container runs, and an environment variable that worked on the laptop is undefined in production. The reason is not Docker and not Next.js but the seam between them, and this post spends most of its time on that seam.
Table of contents
- Standalone output first
- The Dockerfile
- .dockerignore and the image size
- Environment variables: build time versus run time
- Running it: ports, hostname and health
- Deploying the container
- How this fits the rest of the stack
- FAQ
Standalone output first
By default next build assumes the whole node_modules directory will be present at run time, which for a typical app is several hundred megabytes of packages the server never imports. The standalone mode traces which files the server actually needs and copies them into .next/standalone, along with a minimal server.js that starts the app without the next CLI.
// next.config.js
module.exports = {
output: 'standalone',
};
Two things are deliberately not included in that folder: the public directory and the .next/static assets, on the assumption you might serve them from a CDN. In a container you usually want them in the image, so the Dockerfile copies them in explicitly. Forgetting either produces a server that runs and a page with no CSS.
The Dockerfile
Three stages: install dependencies, build, run. Each stage starts from the same small base image so layers are shared, and only the last stage ships.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
The non-root user is not decoration; it is the difference between a compromised app and a compromised container. HOSTNAME=0.0.0.0 makes the server listen on all interfaces rather than loopback, which is the reason a container that logs ready still refuses connections from outside. The Docker multi-stage build post explains the mechanics of --from if the staging is new.
.dockerignore and the image size
The build context is everything in the project directory unless told otherwise, and the two directories that matter most are the ones you least want: a local node_modules and a previous .next build, both of which are large and both of which would be copied in by COPY . . and either shadow the fresh install or bloat the layer.
node_modules
.next
.git
.env*
Dockerfile
.dockerignore
README.md
With standalone output and this ignore file, a typical app lands between 120 and 200MB on the Alpine base. If the image is much larger, something is being copied that should not be, and docker history on the image shows which layer. The .env* line is a security rule as well as a size one: a local env file copied into an image is readable by anyone who can pull the image, which is the Dockerfile secrets problem in its most common form.
Environment variables: build time versus run time
This is the trap. Next.js has two kinds of environment variable and the container makes the difference visible.
NEXT_PUBLIC_variables are inlined into the JavaScript bundle duringnext build. They are string replacements, not lookups. Whatever value exists in the builder stage is what ships; setting them on the running container does nothing, because the bundle was already written.- Everything else is read from
process.envat run time by server code: route handlers, server components, middleware. Those are set on the container and can change between deploys without a rebuild.
The failure looks like this: NEXT_PUBLIC_API_URL works in development, the platform sets it on the service, the app ships with undefined in the browser. The fix is to pass it into the build stage as a build argument:
FROM node:22-alpine AS builder
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# ...
RUN npm run build
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t myapp .
The better fix, where the design allows it, is to need fewer public variables: have the browser call relative paths that the server proxies, so the API URL is a server-side variable and the build is environment-agnostic. The .env versus .env.local post covers how the framework loads the files in development, which is where the confusion starts.
Running it: ports, hostname and health
Locally:
docker build -t myapp .
docker run -p 3000:3000 -e DATABASE_URL=postgres://... myapp
For development with hot reload, do not use this image; use a separate Dockerfile.dev that runs next dev with the source mounted as a volume, or simply run next dev on the host. The production image is for production. Add a health route, a route handler at /api/health that returns 200, so a platform can tell a started container from a working one, and run the container with a restart policy so a crash is a restart rather than an outage. The Dockerfile RUN and port post covers the EXPOSE and publish distinction that confuses the first run.
Deploying the container
The image runs anywhere containers run, which is the point of building it. On a VPS that means a registry push, a pull and a process manager, which the deploy Next.js to a VPS post walks through. On a platform it means connecting the repository and letting the platform build the Dockerfile on push.
On RunxBuild a Dockerfile in the repository is detected and built as a Docker service: the build log shows each stage, the live route appears when the container reports ready, run-time environment variables are set on the service in the dashboard, and a bad deploy rolls back to the previous image in one click. The BasicMini plan at $13 a month (1 vCPU, 1GB) is the comfortable size for a Next.js container with a database behind it, Dev at $4 runs a small one, and autoscaling moves between plans on CPU. The Docker services docs cover the build detection and the environment-variable settings, which is where the build-argument question from the previous section gets answered per platform.
How this fits the rest of the stack
Standalone output, a three-stage Dockerfile, a .dockerignore that excludes the two big directories and the env files, and a clear line between NEXT_PUBLIC_ variables baked at build and everything else read at run time. That is a Next.js container that is small, runs as a non-root user, and behaves identically on a laptop, a VPS and a platform. The RunxBuild hosting calculator prices the container service and its database together so the monthly figure is known before the first push. Build it once, tag it, and let the build log tell you when a variable went missing.
Useful related references:
- Docker Multistage Build: Smaller Images, Separate Build and Runtime
- Deploy Next.js to a VPS: The Full Path, and What It Costs You Afterwards
- .env vs .env.local: Which File Wins, and Which One Gets Committed
- Docker services on RunxBuild
FAQ
Do I need output standalone to run Next.js in Docker?
Not strictly, but without it the image must carry the full node_modules directory and run the next CLI, which produces images of a gigabyte or more. Standalone traces only the files the server needs and emits a server.js, bringing a typical image down to under 200MB on an Alpine base. Every official example uses it.
Why is my Next.js Docker image so large?
Usually one of three things: standalone output is not enabled, a local node_modules or .next directory is being copied in because there is no .dockerignore, or the final stage is built from the builder stage rather than a fresh base image. Run docker history on the image to see which layer carries the weight.
Why is NEXT_PUBLIC_ variable undefined in the container?
Because NEXT_PUBLIC_ variables are inlined into the bundle at build time, and the build ran without them. Setting them on the running container is too late. Pass them as build arguments into the builder stage, or redesign so the browser calls relative paths and the real URL is a server-side variable read at run time.
Do I need sharp in the Docker image for next/image?
In production Next.js uses sharp for image optimisation and installs it as a dependency in recent versions, so standalone output includes it. On Alpine images it needs the runtime libraries, which the official node Alpine image provides. If image optimisation fails in the container, check the build log for a sharp install error before anything else.
How do I run Next.js in Docker with hot reload for development?
Use a separate development Dockerfile that installs dependencies and runs next dev, and mount the source directory as a volume so changes are picked up. Do not reuse the production image, which has no source and no dev server. Many teams skip the container in development entirely and run next dev on the host against containerised services.