
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.
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 --buildAnd 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
appto 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 get502 Bad Gatewayuntil 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.
const nextConfig = {
output: 'standalone',
}
export default nextConfigThis 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:
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
# 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, sobun installand 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 buildneeds yourNEXT_PUBLIC_*vars to inline them. If youCOPY .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 oneRUNand leaves no trace. - The
HEALTHCHECK. This is whatdocker-rolloutpolls. 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.
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:
- appOnly 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:
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.11is 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-rolloutVerify it:
docker rollout --helpStep 7: The Deploy Script
#!/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 psMake it executable and run it:
chmod +x deploy.sh
./deploy.shWhat Actually Happens During a Rollout
Worth understanding, because it explains why there's no gap:
- Scale up.
docker-rolloutstarts a secondappcontainer alongside the existing one. Both are running. - Wait for healthy. It polls the new container's
HEALTHCHECKuntil 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. - 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. - Drain.
--wait-after-healthy 5gives in-flight requests five seconds to finish on the old container. - 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 theset $upstream ...variable form. This cost me a solid hour of thinking rollout was broken.- Don't publish a host port on the
appservice. Two containers can't bind3000on the host. Only the proxy gets aports:entry. docker builder prune -afin a cleanup cron will destroy your build cache. Every deploy then becomes a cold build. Use--filter until=168hand keep a week.docker image prune -afre-pulls your base images every time. Use plain-ffor dangling images only.- Ubuntu's
/bin/shis dash. If you runsh deploy.shand the script uses bash arrays or[[ ]], you get cryptic syntax errors. Theexec /bin/bashguard 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.