
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.
docker compose up -d Causes DowntimeIt helps to know exactly what breaks, because it explains every decision below.
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.
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.
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.
# 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:
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..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.HEALTHCHECK. This is what docker-rollout polls. Without it, rollout has no idea when your app is ready.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.
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.
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 --help#!/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.shWorth understanding, because it explains why there's no gap:
docker-rollout starts a second app container alongside the existing one. Both are running.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.app, and because nginx re-resolves every 2 seconds, it starts using the new one automatically.--wait-after-healthy 5 gives in-flight requests five seconds to finish on the old container.At no point is there zero containers listening. That's the whole game.
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.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./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.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.