Skip to content
Next.js Zero Downtime Deployment with Docker Rollout
NextJS

Next.js Zero Downtime Deployment with Docker Rollout

Deploy Next.js on a VPS with Docker and zero downtime using docker-rollout. Health checks, nginx DNS re-resolution, and a copy-paste deploy script.

10 min read

A while back I wrote Next.js Deployment Script for Zero Downtime on VPS with PM2, where we built the app into a temp directory and swapped it with .next. That approach still works fine, but the moment you move to Docker, it falls apart completely. There is no .next directory to swap on the host anymore — the build lives inside an image.

So what do most people do with Docker? This:

docker compose up -d --build

And that command has a dirty secret: it stops the old container before the new one is ready. Your users get connection refused for anywhere between 5 and 40 seconds, depending on how heavy your app is. On a Next.js app with a decent bundle, it is not a small window.

In this article we'll fix that with docker-rollout — a tiny Docker CLI plugin that doubles your containers, waits for the new ones to pass their health check, and only then kills the old ones.

Why docker compose up -d Causes Downtime

It helps to know exactly what breaks, because it explains every decision below.

  • Compose recreates in place: When an image changes, Compose stops the existing container and starts a new one with the same name. Between those two steps, nothing is listening.
  • Nobody waits for readiness: Even after the container starts, Next.js needs a second or two to boot the server. Compose considers the job done the moment the process starts, not when it can actually serve a request.
  • Your proxy caches the IP: This is the one that catches everyone. nginx resolves app to a container IP once at startup and holds onto it forever. Start a new container, it gets a new IP, and nginx keeps proxying to a container that no longer exists. You get 502 Bad Gateway until you reload nginx.

docker-rollout solves the first two. The third one we have to fix ourselves, and I'll show you how — it's a two-line nginx change that took me an embarrassingly long time to find.

Prerequisites

  • A VPS with Docker and the Compose plugin installed.
  • A Next.js app you can already build locally.
  • A domain pointed at your VPS.

Step 1: Enable Standalone Output

Next.js can trace exactly which files your app needs and bundle them into a self-contained folder. Without this, you end up copying the entire node_modules into your image and your deploys get slow and heavy.

next.config.ts
const nextConfig = {
  output: 'standalone',
}
 
export default nextConfig

This produces .next/standalone with its own minimal node_modules and a server.js you can run directly.

Step 2: Add a Health Check Route

docker-rollout needs a way to ask "are you actually ready?" Without a health check it has nothing to wait on, and you're back to guessing with sleep.

Create a dead-simple route:

app/api/health/route.ts
export const dynamic = 'force-dynamic'
 
export async function GET() {
  return Response.json({ status: 'ok' })
}

Keep this route stupid. Don't check your database or call an external API here. If your DB has a hiccup, you want the deploy to succeed and the app to serve pages, not have every rollout fail.

Step 3: Write the Dockerfile

Dockerfile
# syntax=docker/dockerfile:1
 
FROM oven/bun:1.3-alpine AS builder
 
WORKDIR /app
 
COPY package.json bun.lock ./
 
# Cache mount survives layer invalidation, so a lockfile change re-resolves
# but does not re-download every tarball.
RUN --mount=type=cache,target=/bun-cache \
    bun install --frozen-lockfile --cache-dir=/bun-cache
 
COPY . .
 
# .next/cache is Next's incremental compile cache. `COPY . .` above invalidates
# this layer on every code change, so without a cache mount every deploy is a
# cold build.
# The .env is *mounted*, not copied — it never lands in an image layer.
RUN --mount=type=secret,id=env,target=/app/.env \
    --mount=type=cache,target=/app/.next/cache \
    bun run build
 
FROM oven/bun:1.3-alpine
 
WORKDIR /app
 
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
 
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
 
EXPOSE 3000
 
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=5 \
  CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
 
CMD ["bun", "server.js"]

Three things here are doing real work:

  • The cache mounts. COPY . . invalidates every layer below it whenever any file changes, which is every single deploy. The cache mounts live outside the layer system, so bun install and the Next compile cache survive. This took one of my builds from ~3 minutes down to under 40 seconds.
  • The secret mount for .env. next build needs your NEXT_PUBLIC_* vars to inline them. If you COPY .env . it is baked into a layer forever, and anyone who pulls the image can read your secrets. A secret mount is available during that one RUN and leaves no trace.
  • The HEALTHCHECK. This is what docker-rollout polls. Without it, rollout has no idea when your app is ready.

Step 4: Compose With a Proxy in Front

The app container must not bind a host port. If it did, you could never run two of them at once — the second would fail with "port is already allocated", and doubling containers is the entire trick.

docker-compose.yml
services:
  app:
    image: next-app:latest
    restart: unless-stopped
    env_file: .env
    expose:
      - "3000"
 
  proxy:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "3005:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

Only the proxy touches the host. The app is reachable inside the Docker network as app:3000, and Docker load-balances that name across every running replica for free.

Step 5: Make nginx Re-Resolve DNS

Here's the part that will silently ruin your day.

nginx resolves upstream hostnames once, at startup. When docker-rollout starts a new container and removes the old one, the IP changes, and nginx happily keeps sending traffic into the void. You'll see 502s and swear the rollout is broken when it worked perfectly.

The fix is to force runtime resolution by putting the upstream in a variable:

nginx/default.conf
server {
    listen 80;
    server_name _;
 
    # Docker's embedded DNS server. valid=2s re-checks every 2 seconds,
    # so nginx follows containers as they come and go.
    resolver 127.0.0.11 valid=2s ipv6=off;
 
    location / {
        # Using a variable here is what forces nginx to resolve at request
        # time instead of caching the IP at startup. This line is the whole
        # trick — a literal `proxy_pass http://app:3000;` will NOT work.
        set $upstream http://app:3000;
        proxy_pass $upstream;
 
        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_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
 
        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
    }
}

127.0.0.11 is Docker's built-in DNS resolver — it's the same on every Docker network, so you can copy this as-is.

Step 6: Install docker-rollout

It's a single shell script that installs as a Docker CLI plugin:

mkdir -p ~/.docker/cli-plugins
curl -fsSL https://raw.githubusercontent.com/wowu/docker-rollout/main/docker-rollout \
  -o ~/.docker/cli-plugins/docker-rollout
chmod +x ~/.docker/cli-plugins/docker-rollout

Verify it:

docker rollout --help

Step 7: The Deploy Script

deploy.sh
#!/bin/bash
# Zero-downtime deploy behind nginx, using docker-rollout.
# Ubuntu's /bin/sh is dash, not bash. If this gets run with `sh deploy.sh`,
# re-exec under bash so arrays and [[ ]] actually work.
if [ -z "${BASH_VERSION:-}" ]; then
  exec /bin/bash "$0" "$@"
fi
set -euo pipefail
 
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
IMAGE="next-app:latest"
SERVICE="app"
PORT="3005"
HEALTH_URL="http://127.0.0.1:${PORT}/api/health"
 
[[ -f .env ]] || { echo "Missing .env"; exit 1; }
 
echo "Pulling latest code..."
git pull --ff-only || exit 1
 
echo "Building ${IMAGE}..."
# --secret id=env: `next build` needs .env for NEXT_PUBLIC_* inlining, and it
# is mounted rather than copied so it never lands in a layer.
DOCKER_BUILDKIT=1 docker build --secret id=env,src=.env -t "$IMAGE" .
 
# The proxy is the only host-bound container, start it if it isn't up.
docker compose up -d --remove-orphans proxy
 
if [ "$(docker compose ps -q "$SERVICE" | grep -c .)" -eq 0 ]; then
  echo "No running ${SERVICE} — starting it..."
  docker compose up -d "$SERVICE"
else
  echo "Rolling out ${SERVICE}..."
  # --wait-after-healthy lets in-flight requests drain on the old containers
  # once the new ones are already taking traffic.
  docker rollout "$SERVICE" --timeout 90 --wait-after-healthy 5
fi
 
echo "Waiting for ${HEALTH_URL} ..."
for i in $(seq 1 30); do
  if curl -sf --max-time 2 "$HEALTH_URL" >/dev/null; then
    echo "Healthy"
    break
  fi
  sleep 2
done
 
echo "Cleaning up..."
docker container prune -f >/dev/null 2>&1 || true
 
# -f, not -af: `-a` also deletes the base images (oven/bun, the dockerfile
# frontend) that nothing is currently running, forcing a re-pull next build.
docker image prune -f >/dev/null 2>&1 || true
 
# Keep a week of build cache. `builder prune -af` wipes every cache mount on
# each deploy, which makes the next build start from scratch — the exact
# opposite of what we set up in the Dockerfile.
docker builder prune -f --filter until=168h >/dev/null 2>&1 || true
 
echo "Done → ${HEALTH_URL}"
docker compose ps

Make it executable and run it:

chmod +x deploy.sh
./deploy.sh

What Actually Happens During a Rollout

Worth understanding, because it explains why there's no gap:

  1. Scale up. docker-rollout starts a second app container alongside the existing one. Both are running.
  2. Wait for healthy. It polls the new container's HEALTHCHECK until it passes or the timeout hits. If it never goes healthy, the rollout aborts and your old container is still serving traffic — a failed deploy is a non-event.
  3. Traffic shifts. Docker's DNS now returns both IPs for app, and because nginx re-resolves every 2 seconds, it starts using the new one automatically.
  4. Drain. --wait-after-healthy 5 gives in-flight requests five seconds to finish on the old container.
  5. Scale down. The old container is stopped and removed.

At no point is there zero containers listening. That's the whole game.

Gotchas I Hit So You Don't Have To

  • proxy_pass http://app:3000; written literally will not work. It resolves once and caches forever. You must use the set $upstream ... variable form. This cost me a solid hour of thinking rollout was broken.
  • Don't publish a host port on the app service. Two containers can't bind 3000 on the host. Only the proxy gets a ports: entry.
  • docker builder prune -af in a cleanup cron will destroy your build cache. Every deploy then becomes a cold build. Use --filter until=168h and keep a week.
  • docker image prune -af re-pulls your base images every time. Use plain -f for dangling images only.
  • Ubuntu's /bin/sh is dash. If you run sh deploy.sh and the script uses bash arrays or [[ ]], you get cryptic syntax errors. The exec /bin/bash guard at the top handles it.
  • Your health check must not depend on anything external. I've watched a deploy roll back because Redis blipped for two seconds during the check.

Conclusion

The PM2 approach swapped directories on the host. The Docker approach swaps whole containers, and docker-rollout handles the tricky part — never letting the count of healthy containers hit zero.

Three pieces have to be in place, and if any one is missing you'll still see downtime: a health check so rollout knows when to switch, no host port on the app so two containers can coexist, and nginx re-resolving DNS so traffic actually follows the new container. Get those right and ./deploy.sh becomes something you can run in the middle of the day without thinking about it.