Skip to content

Docker

Docker packages your app and its dependencies into an image that can run on any machine with Docker installed. You don’t need Node installed on the server.

Before starting, read Before You Deploy. You don’t need to complete the standalone output setup separately — the Dockerfile handles it.

Create web/Dockerfile:

# ---- Build stage ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Build-time public values. Anything starting with NEXT_PUBLIC_ is baked into
# the browser bundle, so it must be present during `next build`. Add one
# ARG + ENV pair per variable your project uses.
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN npm run build
# ---- Runtime stage ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Also create web/.dockerignore to keep secrets and build artifacts out of the image:

node_modules
.next
.env
.env.*
e2e
test-results
playwright-report
coverage
.husky
.git
.vscode
.idea
*.log

.env* files are excluded on purpose. Build-time NEXT_PUBLIC_* values come in via --build-arg. Runtime secrets come in via --env-file when you run the container. Baking a .env file into an image layer exposes its contents to anyone who can pull the image.

Terminal window
# Build — pass one --build-arg per NEXT_PUBLIC_* variable.
# These are baked into the browser bundle and must be set here, not at run time.
docker build \
--build-arg NEXT_PUBLIC_API_BASE_URL="https://api.example.com" \
-t my-app:latest ./web
# Run — server-side secrets come in at run time via --env-file.
docker run -d \
--name my-app \
-p 3000:3000 \
--env-file ./web/.env.production \
--restart unless-stopped \
my-app:latest

Two reasons for the split:

  • NEXT_PUBLIC_* values must exist during next build. Next.js bakes them into the JavaScript bundle at compile time. Passing them only at docker run is too late.
  • Server-side secrets must not live in the image. Image layers travel with the image to any registry it’s pushed to. The --env-file flag reads from the host at run time, keeping secrets out of the image entirely.

If your platform has a managed secret store (AWS Secrets Manager, Azure Key Vault, Kubernetes Secrets, etc.), prefer that over --env-file for runtime values.

Docker still needs a reverse proxy in front of it for TLS. Two options:

External proxy: Run nginx on the host and point it at http://127.0.0.1:3000 (the container’s exposed port). Use the same nginx config from the Linux guide.

Sidecar in Docker Compose: Add a Caddy service alongside the app. Caddy fetches and renews a Let’s Encrypt certificate automatically.

docker-compose.yml:

services:
web:
build:
context: ./web
args:
NEXT_PUBLIC_API_BASE_URL: "https://api.example.com"
env_file: ./web/.env.production
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
volumes:
caddy_data:

Caddyfile:

app.your-domain.com {
reverse_proxy web:3000
}
Terminal window
docker ps # "my-app" should show as Up
docker logs -f my-app # tail the app logs

Open your site in a browser. It should load over HTTPS without a port number.