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:
- Build a minimal standalone image in the cloud (GitHub Actions).
- Push it to GHCR (GitHub Container Registry).
- 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-rootuser (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.
NEXT_PUBLIC_(Build-time): These variables are baked into the JS bundle duringnext build. If you change them on the server in.env, nothing will change. You need to pass them to GitHub Actions asbuild-args.Secrets (Runtime):
DATABASE_URL,JWT_SECRET. They must not be put into the Docker image. They are injected at container start viadocker-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.
Everything was done quickly and efficiently. I recommend.
Akelebra · VPS setup, server setup
2026-01-17 · ⭐ 5/5
Everything was done quickly and efficiently. I recommend.
Everything went well; the contractor responded quickly to questions and helped resolve the issue. Thanks!
visupSTUDIO · VPS setup, server setup
2025-12-16 · ⭐ 5/5
Everything went well, the contractor responded quickly to questions and helped resolve the issue. Thank you!
Everything was done promptly. We'll use them again. Highly recommend!
rotant · VPS setup, server setup
2025-12-10 · ⭐ 5/5
Everything was done promptly. We'll continue to use their services. I recommend!
Everything was done promptly. Mikhail is always available. We'll continue to contact him.
samstiray · VPS setup, server setup
2025-12-10 · ⭐ 5/5
Everything was done promptly. Mikhail is always available. We'll continue to reach out
Mikhail is a professional! He's shown this in practice more than once.
Vadim_U · VPS setup, server configuration
A settled customer2025-12-03 · ⭐ 5/5
Mikhail, a professional! Not the first time he's demonstrated this in practice.