Deploy Next.js with Docker, Nginx, and GitHub Actions
Deploy Next.js reliably: choose the right output, build a lean Docker image, configure Nginx, publish with GitHub Actions, and plan safe rollbacks.

A production Next.js deployment is a chain of decisions, not a Dockerfile copied from a snippet. First decide whether the application needs a Node.js runtime. Then make the build reproducible, keep the runtime image small, place a reverse proxy at the public boundary, publish immutable images, and design the rollback before the first release.
This guide presents a conservative self-hosting baseline for the App Router. It is intended for teams that deliberately want to own their infrastructure. A managed platform can still be the right answer when infrastructure control is not a product requirement.
Choose static export or a Node.js runtime first
Do not put a static site in a Node container merely because the framework supports it. Choose the simplest output that supports the product.
| Requirement | Static export | Node.js runtime |
|---|---|---|
| Fully prerendered routes | Strong fit | Supported |
| Request-time cookies or headers | Not supported in page rendering | Supported |
| Server Actions or route handlers requiring runtime code | Not supported | Supported |
| Built-in runtime image optimization | Requires another approach or custom loader | Supported |
| ISR and runtime revalidation | Not supported as a pure export | Supported |
| Simple CDN/object storage hosting | Strong fit | Requires server/container |
If the site is purely static, use output: "export" and serve the generated assets through a CDN or Nginx. If it needs runtime Next.js features, use the Node output. The current Next.js self-hosting guide documents the runtime, proxy, image, environment, and cache considerations.
The rest of this guide assumes a Node.js runtime.
Produce a standalone Next.js output
Enable standalone output so the build traces the production files the server needs:
// next.config.mjs
const nextConfig = {
output: "standalone",
};
export default nextConfig;The standalone directory contains a minimal server and traced dependencies. Public files and .next/static must also be available in the final runtime image.
Keep environment-variable timing clear. Values prefixed with NEXT_PUBLIC_ are exposed to the browser and normally inlined during the build. Server-only runtime values must not use that prefix. Never place secrets in Docker build arguments, copied .env files, or the public bundle.
Build a reproducible multi-stage image
Docker recommends multi-stage builds so build tools and source files do not automatically enter the final image.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /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
CMD ["node", "server.js"]Add a .dockerignore so the build context excludes files that do not belong in the image:
.git
.github
.next
node_modules
npm-debug.log*
.env*
coverage
playwright-report
test-resultsReview the exception if your build genuinely needs a non-secret environment file. In most production pipelines, inject required non-public values at runtime and keep secrets in the platform's secret store.
Why this structure is safer
- ●
npm ciinstalls the exact lockfile graph and fails if the manifest and lockfile disagree. - ●The builder has the compiler and source; the runner receives only the traced runtime output and public assets.
- ●The application runs as an unprivileged user.
- ●The image has a single explicit process and port.
Pin a supported Node major version and schedule regular rebuilds for base-image security updates. For higher supply-chain assurance, pin base images by digest and update them through a controlled dependency process.
Test the container as an artifact
Building successfully is not the same as starting successfully. Run the same image you intend to publish:
docker build --pull -t example/app:test .
docker run --rm -p 3000:3000 --env-file .env.runtime example/app:testThen verify:
- 1.The root and representative dynamic routes return the expected status.
- 2.Static chunks under
/_next/static/load successfully. - 3.Public images and fonts resolve.
- 4.Runtime environment values are present without leaking into client output.
- 5.The process handles termination and stops cleanly.
Add a lightweight health endpoint that checks the application process. Keep deeper dependency checks separate so a temporary downstream issue does not restart every healthy application instance.
// app/api/health/route.js
export function GET() {
return Response.json(
{ status: "ok" },
{ headers: { "Cache-Control": "no-store" } }
);
}Put Nginx in front of Next.js
Next.js recommends a reverse proxy rather than exposing its server directly. Nginx can terminate TLS, enforce request limits, normalize public routing, and absorb slow-client behavior before traffic reaches the application.
upstream nextjs_app {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://nextjs_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}The official Nginx reverse-proxy guide explains proxy_pass, forwarded headers, and buffering behavior. Add TLS through your chosen certificate manager before production traffic. Redirect HTTP to HTTPS only after the certificate path is working.
Do not copy a global cache rule onto every response. Next.js already sets long-lived immutable caching for fingerprinted assets, while dynamic HTML and authenticated responses require different policies. A reverse proxy or CDN that ignores Vary, cookies, or private cache controls can serve the wrong page to the wrong user.
Run the container with an explicit service definition
For one server, Compose can make process configuration repeatable:
services:
web:
image: ghcr.io/example/next-app:2026-09-07.1
restart: unless-stopped
env_file:
- .env.runtime
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3Binding to 127.0.0.1 keeps the application port off the public interface; Nginx is the intended entry point. Protect the runtime environment file with appropriate ownership and permissions, and never commit it.
Publish immutable images with GitHub Actions
A useful CI pipeline verifies the source, builds once, and publishes an identifiable artifact. GitHub's official container publishing guide uses maintained Docker actions for authentication, metadata, and build/push.
name: Publish container
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}For a stricter supply-chain posture, pin third-party actions to full commit SHAs and use an update tool to keep those pins current. Branch tags are convenient; immutable SHA tags are what make a release traceable and rollback predictable.
Separate publishing from deployment
Publishing an image and replacing production are different privileges. Keep them separate when the environment matters.
A deployment should:
- 1.Resolve the exact image digest or commit tag approved for release.
- 2.Pull it before changing traffic.
- 3.Start the new container and wait for a health check.
- 4.Run backward-compatible migrations as a deliberate step if required.
- 5.Switch traffic only after the new instance is ready.
- 6.Retain the previous image reference for rollback.
- 7.Record who or what deployed the release and when.
Avoid embedding a long-lived root SSH key in a repository secret and running an unreviewed shell block on the server. Prefer an environment with short-lived identity, protected approvals, scoped permissions, and an auditable deployment mechanism.
Plan database changes for rollback
Application rollback is simple only when the database remains compatible. Use expand-and-contract migrations:
- 1.Add the new column or table without removing the old shape.
- 2.Deploy code that can work during the transition.
- 3.Backfill data separately and observe it.
- 4.Switch reads and writes to the new shape.
- 5.Remove the old schema in a later release.
This costs more steps but prevents a previous application image from crashing against an irreversible schema change. For API design and PostgreSQL patterns, see my Node.js, Express, and PostgreSQL guide.
Handle Next.js caches deliberately
A single Next.js instance with persistent storage has simpler cache behavior than several ephemeral replicas. When you scale horizontally, review how revalidation tags, cached responses, and build versions coordinate across instances. The self-hosting documentation covers multi-instance cache coordination and version-skew concerns.
At minimum:
- ●Do not let old HTML reference chunks already deleted by a new release.
- ●Keep static assets from recent releases available during rollout.
- ●Use a deployment identifier consistently across instances.
- ●Coordinate cache invalidation when more than one server can answer requests.
- ●Test navigation during a rolling deployment, not just before and after it.
Production observability is part of deployment
Collect enough information to answer:
- ●Which release is serving this request?
- ●Is the failure at Nginx, Next.js, a dependency, or the browser?
- ●Are latency and errors isolated to one route or instance?
- ●Did a deploy change Core Web Vitals or server response time?
- ●Is disk, memory, CPU, or connection pressure approaching a limit?
Use structured logs with request IDs, application error reporting, uptime checks from outside the server, and resource monitoring. Keep sensitive headers, tokens, personal data, and request bodies out of logs unless there is a justified and protected need.
Security and reliability checklist
- ●Run the application as a non-root user.
- ●Expose only Nginx publicly; bind the app port to loopback or a private network.
- ●Terminate TLS and automate certificate renewal.
- ●Patch the host, Node base image, Nginx, and dependencies regularly.
- ●Store secrets outside Git and outside the image.
- ●Apply request-body limits and timeouts appropriate to the application.
- ●Back up persistent data and perform restoration tests.
- ●Keep immutable release tags and a documented rollback command.
- ●Test shutdown behavior so deployments do not cut active requests abruptly.
- ●Monitor health, errors, latency, saturation, and disk space.
A release checklist you can automate
- 1.Install from the lockfile.
- 2.Run linting, tests, and a production build.
- 3.Build the Docker image with an updated base image.
- 4.Start the image and probe representative routes and assets.
- 5.Scan dependencies and the image according to your risk policy.
- 6.Publish an immutable tag and record its digest.
- 7.Deploy to a protected environment.
- 8.Wait for health and smoke checks before switching traffic.
- 9.Verify logs, errors, and key user journeys.
- 10.Roll back automatically or manually when the acceptance window fails.
The production principle
The strongest deployment is understandable under pressure. You know what was built, what is running, where traffic enters, how secrets arrive, which checks prove health, and how to return to the previous version.
Docker, Nginx, and GitHub Actions are useful because they can make those boundaries explicit. They do not remove the need to own patching, observability, backup, access control, and incident response. If that operational responsibility does not serve the product, choose a managed platform. If it does, design the system so the safe path is also the routine path.
See the production applications in my selected work, or read how the site itself is structured in my Next.js portfolio build guide.