// DevOps

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

Published on 2026-09-22

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

In 2026 the industry standard is separate build:

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

Chapter 1. Dockerfile: multi-stage and standalone

You get a small and fast image in standalone mode. Next.js itself figures out which files and parts of node_modules are actually needed for the server to run, and copies only them.

dockerfile
# 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: run as non-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 health check
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"]

The node:24-alpine image — on the current Node.js LTS branch. Check support for the chosen version at endoflife.date/nodejs: as of September 2026 branches 24, 22 and 20 are supported.

What this provides

  • Size: The image is ~200 MB versus ~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 hung on startup 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 hardware.

1. Preparation and build

yaml
- 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 ${GITHUB_REPOSITORY,,} syntax lowercases the repository name. Docker registries don’t like uppercase letters, and they are common on GitHub.


2. Build caching

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

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


3. Deploy with healthcheck verification (healthcheck loop)

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

bash
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 error in environment variables, the script will detect it, not update the proxy server (Caddy/Nginx), and end the action with an error. 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 hits.

  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. They need to be passed to GitHub Actions as build-args.

  2. Secrets (Runtime): DATABASE_URL, JWT_SECRET. They must not be placed into the Docker image. They are pulled in 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 rolled out to both staging and production without rebuilding.

Related topics are covered on the blog: proper Dockerfile, CI/CD from manual deployment to automated and deploying containers via Kamal.

// Reviews

Related reviews

I came with an expensive request to configure a VPS server, but during the consultation Mikhail suggested a much simpler, more affordable solution. In the end I saved time and money. Mikhail — a true expert who works for the client's result, not for the fee. I recommend him!

I came with an expensive request to configure a VPS server, but during the consultation Mikhail suggested a much simpler and more cost-effective solution. In the end I saved budget and time. Mikhail — a true expert who …

kfhzasorin

VPS setup, server setup

2026-05-12 · ★ 5/5

// Contact

Need help?

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

I reply within one business day (03:00-13:00 GMT)

Или оставьте заявку здесь:

Confirm that you are not a bot.

Write and get a quick reply