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.

Modular containers arranged along a deployment track
A conceptual release pipeline with a path back to a previous deployment. AI-generated editorial illustration, not a product screenshot.

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.

RequirementStatic exportNode.js runtime
Fully prerendered routesStrong fitSupported
Request-time cookies or headersNot supported in page renderingSupported
Server Actions or route handlers requiring runtime codeNot supportedSupported
Built-in runtime image optimizationRequires another approach or custom loaderSupported
ISR and runtime revalidationNot supported as a pure exportSupported
Simple CDN/object storage hostingStrong fitRequires 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:

javascript
// 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.

dockerfile
# 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-results

Review 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 ci installs 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:

bash
docker build --pull -t example/app:test .
docker run --rm -p 3000:3000 --env-file .env.runtime example/app:test

Then verify:

  1. 1.The root and representative dynamic routes return the expected status.
  2. 2.Static chunks under /_next/static/ load successfully.
  3. 3.Public images and fonts resolve.
  4. 4.Runtime environment values are present without leaking into client output.
  5. 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.

javascript
// 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.

nginx
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:

yaml
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: 3

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

yaml
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. 1.Resolve the exact image digest or commit tag approved for release.
  2. 2.Pull it before changing traffic.
  3. 3.Start the new container and wait for a health check.
  4. 4.Run backward-compatible migrations as a deliberate step if required.
  5. 5.Switch traffic only after the new instance is ready.
  6. 6.Retain the previous image reference for rollback.
  7. 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. 1.Add the new column or table without removing the old shape.
  2. 2.Deploy code that can work during the transition.
  3. 3.Backfill data separately and observe it.
  4. 4.Switch reads and writes to the new shape.
  5. 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. 1.Install from the lockfile.
  2. 2.Run linting, tests, and a production build.
  3. 3.Build the Docker image with an updated base image.
  4. 4.Start the image and probe representative routes and assets.
  5. 5.Scan dependencies and the image according to your risk policy.
  6. 6.Publish an immutable tag and record its digest.
  7. 7.Deploy to a protected environment.
  8. 8.Wait for health and smoke checks before switching traffic.
  9. 9.Verify logs, errors, and key user journeys.
  10. 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.

Primary references