Why docker compose up -d causes downtime and what zero-downtime means on one host

When an image changes, docker compose up -d recreates the service: it stops the old container, removes it, and starts a new one. Between the old process exiting and the new one accepting connections, nothing listens on the upstream address. Nginx gets connection refusals and returns 502 Bad Gateway. The gap includes the stop grace period, container creation, and your application's boot time (runtime start, config load, connection pools, cache warm-up).

Compose has no built-in rolling update for a standalone host. The deploy.update_config settings are used by Swarm, not by plain docker compose up. On one host, you have to build zero downtime yourself:

  1. Start the new version next to the old one.
  2. Wait until a healthcheck confirms it can serve real traffic.
  3. Point the Nginx upstream at it and run a graceful reload. Existing workers finish their in-flight requests.
  4. Drain the old container, then remove it.

This tutorial is for backend and platform engineers who run one or a few hosts without an orchestrator. A short outage on every deploy seems acceptable until you deploy several times a day.

Illustrative estimate (assumed numbers, not a benchmark):

failed requests per deploy = request rate × gap

Assume 40 req/s and an 8-second gap. That gives 40 × 8 = 320 failed requests per deploy. At 5 deploys a day, that is about 1,600 errors. Each one is a user-visible failure, a retry storm, or a spurious alert.

A) docker compose up -d, image changed: stop + recreate leaves a gap

  Client --> Nginx --> app v1   (stopped, removed)
               |
               +--> nothing listening --> 502 Bad Gateway
               |
               +--> app v2 (create + boot + warm-up) --> serving again

B) Self-built zero downtime: run new next to old, gate, switch, drain

                    +---------------------------+
  Client ---------> | Nginx (reverse proxy)     |
                    | upstream app: blue->green |
                    +----+------------------+----+
                         |                  |
        old upstream     |                  |  (3) switch upstream,
        in-flight only   |                  |      graceful reload
                         v                  v
                  +--------------+   +--------------+
                  | app_blue v1  |   | app_green v2 |
                  +------+-------+   +------+-------+
                         |                  |
                         v                  v
                 (4) drain, then    (1) start next to v1
                     stop + rm      (2) healthcheck passes
                                        before step (3)

Plain docker compose up -d leaves a window with no listener, so Nginx returns 502s; the self-built pattern starts the new container beside the old one, gates it on a healthcheck, gracefully reloads Nginx, then drains and removes the old container.

Who should use this pattern

This pattern fits teams that already run production on Docker Compose and want to stop dropping requests during deploys without adopting an orchestrator. Typical cases:

  • Small product teams on one VM or a few VMs. One host runs Nginx, the application, and maybe a worker or two. Deploys happen from CI or a shell, several times a week or more.
  • Platform engineers supporting such teams. They want one deploy script that every service can reuse, with a predictable rollback path.
  • Services with real users during working hours. An API behind a mobile app or a checkout flow, where every 502 turns into a support ticket, a failed payment, or an alert.
  • Stateless or state-externalized HTTP services. Sessions, uploads, and caches already live in a database, object store, or shared cache, so two copies of the app can run side by side.

It is a poor fit if you deploy rarely and can tolerate a few seconds of errors, if the host has no spare memory for a second copy of the app, if your schema changes cannot be made backward compatible, or if you already need multiple hosts and failover. Those cases are covered in the sections on costs and alternatives below.

How the pattern works end to end

The mechanism has three parts, and each one closes a specific gap.

  1. Two copies of the service, only one live. The Compose file defines app_blue and app_green. At any moment one color receives traffic from Nginx and the other is stopped or idle. Deploying means starting the idle color on the new image while the live color keeps serving. Nothing about the live container changes, so there is no window with no listener.

  2. A healthcheck as the gate. Docker runs the container's healthcheck and records starting, healthy or unhealthy. The deploy script waits for healthy before it touches Nginx. If the new version never becomes healthy, the script stops it and exits, and users never see it.

  3. An Nginx upstream switch with a graceful reload. Nginx proxies to an upstream named app, defined in a small include file. The script rewrites that file to point at the new color, validates it with nginx -t, and runs nginx -s reload. The master process starts new workers that send new requests to the new color. Old workers stop accepting connections, finish their in-flight requests, and exit. After a drain period, the script stops the old color.

Rollback is the same switch in reverse. As long as the old color is still running, you point the upstream back at it and reload. The rest of this article builds each part: the healthcheck, the blue/green services and upstream switch, graceful shutdown, and the full deploy and rollback script.

Defining healthchecks that test readiness

To cut that number, the proxy has to know when a new container can actually serve traffic. Compose's healthcheck block tells it. Docker runs the check inside the container and records the result on the container's state.

  • test is the command to run. Exit code 0 means healthy and 1 means unhealthy.
  • interval is the time between checks.
  • timeout is how long one check may run before Docker counts it as failed.
  • retries is how many consecutive failures it takes to mark the container unhealthy.
  • start_period is a grace window for slow boots. Failures inside it don't count toward retries, and one success marks the container healthy right away.

Make the test a readiness check, not a liveness check. A process that is running but still warming caches or waiting on its database pool should not receive traffic.

Check style What it tests Failure mode
Liveness (shallow) Process up, port open, /ping returns 200 Passes a release with bad config or a broken DB connection
Readiness (deep) /ready verifies DB, cache and migrations A brief outage of a shared dependency marks every instance unhealthy at once

Choose the depth deliberately. During a deploy, a deep check catches a broken release before the switch. Once traffic is flowing, it can turn a short database blip into a total outage if anything acts on the result. A common compromise is a deep check gated only at cutover and a shallow check used afterward.

To read the status, run docker compose ps, which appends (healthy) or (unhealthy) to the status column. For scripts, use docker inspect --format '{{.State.Health.Status}}' <container>, which returns starting, healthy or unhealthy. Standalone Docker does not restart unhealthy containers, so the deploy script must act on this value itself.

services:
  app:
    image: registry.example.com/shop-api:2.4.1
    expose:
      - "8080"
    healthcheck:
      # Readiness, not liveness: /ready returns 200 only when the DB pool,
      # cache and migrations are usable. curl -f exits non-zero on HTTP >= 400.
      # The image must include curl (or swap in wget or a small binary).
      test: ["CMD", "curl", "-fsS", ""]
      interval: 10s      # run the check every 10 seconds
      timeout: 3s        # a check slower than this counts as a failure
      retries: 3         # 3 consecutive failures => unhealthy
      start_period: 30s  # failures here don't count; first success => healthy

  nginx:
    image: nginx:stable
    ports:
      - "80:80"
    depends_on:
      app:
        # Start nginx only after app reports healthy, not merely started.
        condition: service_healthy

A Compose service with a readiness-style healthcheck that uses all five fields, an nginx service that waits for it via service_healthy, and the commands for reading the resulting health status.

Blue/green services and Nginx upstream switching

Instead of recreating one service in place, define two services in the same Compose file, app_blue and app_green, built from the same image reference but each with its own container_name and a network alias on the network shared with Nginx. Because they are separate services, docker compose up -d app_green starts green without touching blue. Blue keeps serving while green starts and its healthcheck reaches healthy. Two separate project names (-p blue, -p green) give the same isolation if you prefer one service definition.

Nginx never references a color directly in its main config. An included file, for example conf.d/upstream.conf, holds a single upstream app { server app_blue:8080; } block, and the server block's proxy_pass targets the app upstream by name. To switch, the deploy script rewrites that one line to app_green, runs nginx -t to validate the result, then runs nginx -s reload. On reload, the master process starts new workers on the new configuration and tells the old workers to stop accepting connections and exit once their in-flight requests finish. Long-lived connections such as WebSockets keep old workers alive until those connections close.

Reloading also handles DNS. Nginx resolves hostnames in an upstream block once, at config load. If a container is recreated and Docker's DNS gives it a new IP, Nginx keeps sending traffic to the stale address until the next reload.

The alternative is resolver 127.0.0.11 valid=10s;, which points at Docker's embedded DNS, combined with a proxy_pass whose target hostname comes from a variable, for example one set with set $backend app_green;. Because the hostname comes from a variable, Nginx resolves it at request time. The cost is that you lose the upstream block and its features, including keepalive pools, max_fails, and multiple servers. URI handling in proxy_pass also changes when it contains variables.

#!/usr/bin/env bash
set -euo pipefail

CONF=./nginx/conf.d/upstream.conf   # mounted into the nginx container's conf.d

if grep -q 'app_blue' "$CONF"; then
  CURRENT=blue; NEXT=green
else
  CURRENT=green; NEXT=blue
fi

docker compose up -d --wait "app_${NEXT}"

cp "$CONF" "$CONF.bak"
printf 'upstream app {\n    server app_%s:8080;\n}\n' "$NEXT" > "$CONF"

if ! docker compose exec -T nginx nginx -t; then
  cp "$CONF.bak" "$CONF"
  echo "nginx -t failed; upstream restored to app_${CURRENT}" >&2
  exit 1
fi

docker compose exec -T nginx nginx -s reload

sleep 30
docker compose stop "app_${CURRENT}"
echo "live color: ${NEXT}"

A deploy script that starts the idle color and waits for it to be healthy, rewrites the included upstream file, validates it with nginx -t, and switches traffic with a graceful nginx -s reload before stopping the old color.

Graceful shutdown and passive failure handling during the switch

Drain before you stop

When Docker stops a container, it sends SIGTERM to PID 1, waits for stop_grace_period (10 seconds by default), and then sends SIGKILL. Your app should catch SIGTERM, stop accepting new connections, finish in-flight requests, and exit. If the entrypoint uses shell form, the shell becomes PID 1 and the app may never receive the signal. Set stop_grace_period slightly longer than your slowest legitimate request, or SIGKILL will cut those requests off.

The order matters. First, point Nginx at the new color and reload. Then wait for the old color's in-flight work to drain. Only then stop the old color. If you stop the old color first, you create the outage you are trying to avoid.

Passive failure handling

Open-source Nginx does not run active health checks. It never probes backends on its own. Active checks come from the commercial ngx_http_upstream_hc_module. What open-source Nginx does provide is passive detection. After max_fails failed attempts within fail_timeout, a server is marked unavailable, and proxy_next_upstream retries the request on another server. This only helps when the upstream lists more than one server. With a single server, Nginx ignores max_fails and never marks it down. By default, Nginx also does not retry non-idempotent requests such as POST.

Long-lived connections

After a reload, old Nginx workers keep their existing WebSocket, SSE, and long-poll connections open. They hold them until the connections close or worker_shutdown_timeout expires. Those clients still get disconnected, so they need reconnect logic.

worker_shutdown_timeout 30s;

events {}

http {
    # Passive detection needs more than one server. With a single server, max_fails is ignored.
    upstream app {
        # After 3 failed attempts within 10s, the server is skipped for the next 10s.
        server app-green-1:8080 max_fails=3 fail_timeout=10s;
        server app-green-2:8080 max_fails=3 fail_timeout=10s;
    }

    # Forward the WebSocket upgrade and close plain requests normally.
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    server {
        listen 80;

        location / {
            proxy_pass
            proxy_connect_timeout 2s;

            # Retry on another server if this one refuses, times out, or returns 502/503.
            # POST and other non-idempotent requests are not retried unless you add non_idempotent.
            proxy_next_upstream error timeout http_502 http_503;
            proxy_next_upstream_tries 2;
            proxy_next_upstream_timeout 5s;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
        }
    }
}

An open-source Nginx config that uses max_fails, fail_timeout and proxy_next_upstream for passive failure handling across two replicas of the live color, and sets worker_shutdown_timeout to cap how long old workers hold long-lived connections after a reload.

A complete deploy and rollback script

The script runs as one unit, and each step protects the step after it. Take an exclusive lock first, for example with flock on a lock file, so two CI jobs cannot both treat the same color as idle. Read the active color from a state file instead of inferring it from running containers. A container that crashed mid-deploy would make that inference wrong.

Pull or build the image, then start only the idle color with docker compose up -d app_green. Poll docker inspect --format '{{.State.Health.Status}}' until it reports healthy or a deadline passes. If it times out, stop the idle color and exit non-zero. Nginx was never touched, so traffic is unaffected.

Once the new color is healthy, rewrite the upstream include file, then run nginx -t followed by nginx -s reload. The config test means a typo fails the deploy instead of taking Nginx down. Send a smoke request through Nginx, not straight to the container, so you test the path users take. Wait at least as long as your longest expected request before stopping the old color, then write the new color to the state file.

Rollback

While the old color is still running, rollback is cheap: restore the previous upstream file and reload. Once it has been stopped, rollback becomes a full deploy of the previous image tag, health polling included. For that reason, record the previous tag in the state file.

Rolling-restart variant

docker compose up -d --scale app=2 --no-recreate adds a replica next to the old one. Compose will not retire old replicas one at a time for you. Your script waits for the new replica to become healthy, reloads Nginx so it re-resolves the service name, then stops and removes the old container by ID.

When to avoid this pattern and what it costs

The pattern is not free. During every deploy you run two full copies of the application, so the host needs enough spare CPU and memory to hold both at once. On a small VM sized for one copy, the new container's startup can push the host into memory pressure. The kernel's OOM killer may then terminate the old container, the new one, or Nginx, which turns a zero-downtime deploy into an outage.

Old and new code also share one database during the switch. Any schema change must therefore be backward compatible. Use expand/contract migrations: add the new column or table first, deploy code that works with both shapes, and drop the old structure only in a later release. Stateful apps have a similar problem. In-process sessions, files written to the container filesystem, and local caches disappear when the old color stops. Move that state into shared storage before adopting this pattern.

Nginx itself remains a single point of failure. Updating the Nginx container needs its own procedure, because the blue/green switch does not cover it. Treat the deploy script, the state file, and the templated config as production code you own. A bug in them can cause an outage. An interrupted run can leave the state file pointing at the wrong color, and two concurrent runs can race each other. Use a lock, such as flock on a lock file, and make the script safe to re-run.

That maintenance cost is real, but it usually costs less than the alternative. Without this pattern, teams schedule maintenance windows, coordinate announcements, and pay engineers to deploy out of hours. Each of those deploys is batched and risky. With the pattern in place, a deploy is a routine daytime command that you can roll back quickly.

How it saves engineering time

The time savings come from removing work that surrounds each deploy, not from making the deploy itself faster.

  • No maintenance windows. If a deploy drops no requests, nobody has to schedule it, announce it, or stay late to run it. Deploys happen during normal hours, when the people who wrote the change are around to watch it.
  • Smaller, more frequent releases. When deploys are cheap, changes ship one at a time instead of in batches. A failed release then points to one change, which makes debugging shorter.
  • Failed releases stop before users see them. The healthcheck gate catches a release that cannot reach its database or crashes on boot. The script exits and the live color keeps serving. There is no incident to triage, only a failed CI job to read.
  • Fast rollback. While the old color is still running, rollback is an upstream rewrite and a reload. That is far quicker than rebuilding and redeploying under pressure.
  • Less alert noise. Deploy-time 502s often trip error-rate alerts. Removing them means on-call engineers stop investigating alerts that are just the deploy.
  • One script for every service. Once written, the same lock, health-poll, switch, and drain logic can be reused across services on the host, so each new service gets zero-downtime deploys without new design work.

Illustrative estimate (assumed numbers, not a measured result):

Assume a team does 5 deploys a week. Without the pattern, assume each deploy needs 30 minutes of out-of-hours coordination and monitoring by one engineer, plus one deploy-related alert a week that takes 20 minutes to confirm as noise. That is 5 × 30 + 20 = 170 minutes a week. With the pattern, assume each deploy is a CI job that one engineer watches for 5 minutes during the day, so 5 × 5 = 25 minutes a week. The assumed difference is about 145 minutes a week. Against that, budget the one-off time to write and test the script, and some ongoing time to maintain it. Plug in your own deploy frequency and coordination overhead to see whether the trade works for your team.

Alternatives and when to move to an orchestrator

Four approaches cover most single-team setups. They differ mainly in who carries the work of draining and health-gating: nobody, your scripts, or a scheduler.

Approach Downtime per deploy Who maintains the logic Survives host failure Fits best
Plain recreate (docker compose up -d) Seconds to minutes Nobody No Internal tools, rare deploys, tolerant users
Blue/green on one host with Nginx None if healthchecks are honest You: script, upstream config, rollback No One host, a few services, daily deploys
Hand-scripted rolling restart of scaled replicas None, but mixed versions serve traffic during the roll You, with more state to track No Stateless services that tolerate version skew
Orchestrator with native rolling updates None, gated on health The scheduler; you maintain manifests Yes, across nodes Multiple hosts, many services, frequent deploys

The script approaches pay off as long as you can read the deploy logic in one sitting. That stops being true in a few predictable ways. When you add a second host, you need a load balancer in front of the hosts and a way to deploy to them in sequence. With many services, each one needs its own color state and its own upstream file. Frequent deploys from several teams need locking so two rollouts don't collide. And once someone asks what happens if the box dies, no amount of single-host scripting can answer it.

When you hit any one of these, you are writing a small, untested scheduler. That's the point to adopt a real one. Its rolling updates, health-gated rollouts and rescheduling onto healthy nodes have been exercised far more than your bash ever will be. Until then, blue/green on one host is the right size. It has few moving parts, failures are easy to see, and rollback is a single command.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to zero downtime deployment docker compose, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.