Русский flag Русский Español flag Español

Modern Next.js deployment: GitHub Actions, Docker, and Zero-Downtime

Published on 2026-03-02

If you still run next build directly on the production server — your server is really suffering. CPU pegged, OOM-kill, 502 errors and long downtimes — this is a classic that needs to end.

In 2026 the industry standard is separate builds:

  1. Build a minimal standalone image in the cloud (GitHub Actions).
  2. Push it to GHCR (GitHub Container Registry).
  3. On the server do only pull + atomic restart.

Chapter 1. The ideal Dockerfile (Multi-stage + Standalone)

The whole secret to a small and fast image is the standalone mode. Next.js itself figures out which files and parts of node_modules are actually needed to run the server, and copies only them.

# syntax=docker/dockerfile:1
# STAGE 1 — Dependencies
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else npm ci; \
  fi

# STAGE 2 — Build
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# STAGE 3 — Production image (Runner)
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Security first: don't run as root
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs

# Copy only standalone build artifacts
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
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Container healthcheck
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1

CMD ["node", "server.js"]

Why this is great?

  • Size: The image weighs ~200 MB vs ~1.5 GB for a typical one.
  • Security: Using a non-root user (nextjs) protects the host system if the container is compromised.
  • Healthcheck: Docker will detect if the app is “stuck” on start and won’t send traffic to it.

Chapter 2. Breakdown of the GitHub Actions Workflow

Your pipeline is split into two stages (jobs): build in the cloud and deploy to your own servers.

1. Preparation and build

- name: Docker meta & tags
  id: prepare
  run: |
    IMAGE_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
    SHORT_SHA="${GITHUB_SHA::8}"
    echo "image_repo=$IMAGE_REPO" >> $GITHUB_OUTPUT
    echo "image_tag=sha-$SHORT_SHA" >> $GITHUB_OUTPUT

Important note: The syntax ${GITHUB_REPOSITORY,,} converts the repository name to lowercase. Docker registries don’t like uppercase letters, and they are common on GitHub.


2. Build caching

cache-from: type=gha
cache-to: type=gha,mode=max

We use native GitHub Actions caching. If you didn’t change package.json, the dependencies installation stage will be skipped, and the build will take 1–2 minutes instead of 10.


3. Smart deploy (Healthcheck Loop)

The most important part — we don’t just tell the server “update”, we check whether the application survived.

for i in {1..60}; do
  STATUS=$(docker inspect --format='{{json .State.Health.Status}}' nextjs 2>/dev/null || echo '"not-found"')
  if [[ $STATUS == '"healthy"' || $STATUS == '"no-healthcheck"' ]]; then
    HEALTHY=1
    break
  fi
  sleep 2
done

If Next.js crashes due to an environment variable error, the script will detect it, not update the proxy server (Caddy/Nginx), and will fail the action. Your old site will keep running, and you’ll receive a notification about the problem.


Chapter 3. Runtime vs Build-time variables

These are the pitfalls that almost everyone falls into.

  1. NEXT_PUBLIC_ (Build-time): These variables are baked into the JS bundle during next build. If you change them on the server in .env, nothing will change. You need to pass them to GitHub Actions as build-args.

  2. Secrets (Runtime): DATABASE_URL, JWT_SECRET. They must not be put into the Docker image. They are injected at container start via docker-compose.

Tip: If possible make the API URL a runtime variable as well via proxying or special config scripts so the same image can be deployed to staging and production without rebuilding.

Related reviews

There were several issues concerning both the technical side and overall understanding. Mikhail responded quickly, resolved the technical problems, and helped me understand them — many thanks. I'm satisfied with the result.

abazawolf · VPS setup, server setup

2026-02-18 · ⭐ 5/5

There were several issues concerning both the technical side and overall understanding. Mikhail responded quickly to the request, helped sort things out and resolved the technical problems and helped clarify understanding, for which a special thank you. I am satisfied with the result.

Need help?

Get in touch with me and I'll help solve the problem

Related Posts