Docker and Deployment Configuration guide Advanced

Zero-Downtime Deploys in Dokploy with Docker Health Checks

Dokploy runs apps as Docker Swarm services, so rolling updates are already there — they just need a real health check and an update policy. Plus how to prove no request was dropped.

A Docker Swarm rolling update replacing one task at a time behind Traefik

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Docker
27.x, Swarm mode active
Dokploy
v0.x, June 2026
Traefik
3.x
Application
Node.js 22 HTTP service

Before you start

  • A Dokploy installation with at least one deployed application
  • An HTTP endpoint in your app that reports readiness
  • Comfort with docker service commands on the host
On this page

Dokploy runs applications as Docker Swarm services, which means the rolling update machinery is already there. What is usually missing is a health check — and without one, Swarm has no way to know when the new container is ready, so it shifts traffic to a process that is still starting.

Two pieces of config fix it: a HEALTHCHECK on the image, and an update_config block on the service.

What zero-downtime actually requires

Four things must all be true. Missing any one of them puts a gap in the deploy:

  1. The new container reports healthy only when it can serve requests.
  2. Swarm waits for that signal before routing traffic to it.
  3. The old container keeps serving until the new one is ready.
  4. The old container drains in-flight requests before it exits.

Points 1 and 2 are the health check. Point 3 is order: start-first. Point 4 is graceful shutdown in your application, which no orchestrator can do for you.

Add a health check to the image

The check runs inside the container, so it can only use tools the image actually has. Alpine-based images generally have neither curl nor wget with --spider support, which is why so many health checks fail with executable file not found.

On a Node 22 image, use the runtime you already have:

Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json bun.lock ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/healthz')\
    .then(r => process.exit(r.ok ? 0 : 1))\
    .catch(() => process.exit(1))"

CMD ["node", "server.js"]

--start-period=30s is the one people leave out. During that window, failed checks do not count against --retries, which stops a slow-starting app from being killed before it has finished booting.

The endpoint itself should be cheap and should not touch the database:

src/routes/health.ts
// Liveness only: is this process able to serve HTTP?
// Dependency health belongs in monitoring, not here.
app.get('/healthz', (c) => c.json({ status: 'ok' }, 200));

Configure the rolling update policy

In Dokploy, add this to the Compose configuration for the service (or the advanced Swarm settings for a plain application):

compose.yaml
services:
  app:
    image: ghcr.io/example/app:1.4.2
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        monitor: 30s
        failure_action: rollback
        max_failure_ratio: 0
      rollback_config:
        parallelism: 1
        order: stop-first
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3

What each value does in practice:

  • parallelism: 1 — replace one task at a time. With two replicas, half your capacity is always serving.
  • monitor: 30s — how long Swarm watches a new task before deciding the update step succeeded. Set it longer than your app takes to fail. An app that crashes 20 seconds in with a monitor: 10s will be declared healthy.
  • failure_action: rollback — revert automatically. The default is pause, which leaves you half-deployed and needing a manual docker service update --force to get unstuck.
  • max_failure_ratio: 0 — any failed task fails the update.

Get the Traefik labels in the right place

Under Swarm, Traefik reads labels from the service, not the container. A label under services.app.labels is attached to each container and Traefik’s Swarm provider will not see it.

compose.yaml
services:
  app:
    deploy:
      # Correct: service-level labels.
      labels:
        - traefik.enable=true
        - traefik.http.routers.app.rule=Host(`app.example.com`)
        - traefik.http.services.app.loadbalancer.server.port=3000

This is the single most common reason an app deploys successfully in Dokploy and then returns a 404 from Traefik.

Watch a deploy roll

Trigger the deploy from Dokploy, then watch it on the host:

bash
docker service ps "$(docker service ls --filter name=app -q)" --no-trunc
Expected output during a start-first update
bash
NAME       IMAGE                       DESIRED STATE  CURRENT STATE
app.1      ghcr.io/example/app:1.4.3   Running        Running 12 seconds ago
 \_ app.1  ghcr.io/example/app:1.4.2   Shutdown       Shutdown 3 seconds ago
app.2      ghcr.io/example/app:1.4.2   Running        Running 6 minutes ago

The update state is machine-readable, which is what you want in CI:

bash
docker service inspect app --format '{{json .UpdateStatus}}' | jq
Expected output
bash
{
  "State": "completed",
  "StartedAt": "2026-06-12T09:14:02.113Z",
  "CompletedAt": "2026-06-12T09:14:51.884Z",
  "Message": "update completed"
}

"State": "rollback_completed" means the new version failed its monitor window and Swarm put the old one back.

Prove no request was dropped

Health checks passing is not the same as users not seeing errors. Run traffic through the deploy and count.

probe.sh
#!/usr/bin/env bash
# Run during a deploy. Any line that is not 200 is a dropped request.
fail=0
for _ in $(seq 1 600); do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 https://app.example.com/healthz)
  [ "$code" = "200" ] || { echo "got $code"; fail=$((fail + 1)); }
  sleep 0.2
done
echo "non-200 responses: $fail"
Expected output
bash
non-200 responses: 0

If you see a burst of 502 at the moment the old task shuts down, your application is not draining connections. Handle SIGTERM: stop accepting new connections, finish in-flight requests, then exit. Swarm sends SIGTERM and waits stop_grace_period (10 seconds by default) before SIGKILL.

Two details make draining actually work. The process must receive the signal, which means it has to be PID 1 in the container — a shell-form CMD wraps it in /bin/sh, which does not forward signals, so use the exec form CMD ["node", "server.js"]. And stop_grace_period must exceed your longest normal request, otherwise the drain is cut short by SIGKILL and you have simply moved where the failure happens.

Raise it explicitly for anything with slow endpoints:

compose.yaml
services:
  app:
    stop_grace_period: 30s

Common errors

exec: "curl": executable file not found in $PATH — the health check calls a binary the image does not contain. Use the language runtime instead, as above.

Task state cycles Preparing → Failed → Preparing. The image cannot be pulled on that node. Check registry credentials; in Swarm they must be pushed to the service with --with-registry-auth, which Dokploy does for its own deploys but not necessarily for a manual docker service update.

update paused and the deploy never finishes. failure_action is the default pause. Fix the underlying failure, then docker service update --force <service>.

Both tasks are Running but Traefik still 404s. Labels are on the container instead of under deploy.labels.

port is already allocated with order: start-first. The service publishes a port in host mode, so two tasks cannot coexist on one node. Either use the default ingress mode or accept stop-first for that service.

The new version reports healthy, then errors in production. monitor is shorter than the time it takes your app to fail. Raise it to cover a realistic failure window — 60 seconds is not excessive.

Production notes

start-first means two versions of your application run simultaneously for a few seconds. Everything they share must tolerate that:

  • Database migrations must be backwards compatible. Add a nullable column, deploy, backfill, then make it non-null in a later release. A migration that drops a column the old version still reads will error during every deploy window.
  • Cache keys must be versioned if the serialisation format changed.
  • Background jobs enqueued by the new version must be readable by the old one until the update completes.

If a change cannot satisfy those constraints, use order: stop-first for that release and accept a short planned gap. A ten-second outage you chose is better than a corrupted write you did not.

Finally, keep replicas: 2 or more for anything user-facing. With one replica a node reboot is downtime no update policy can prevent.

Frequently asked questions

Why does Dokploy still drop requests during a deploy?

The service has no health check, so Swarm considers the task healthy the moment the process starts. It shifts traffic to a container that is still booting, and those requests fail. Add a `HEALTHCHECK` and the ordering problem disappears.

What is the difference between order start-first and stop-first?

`start-first` brings the new task up before removing the old one, so both run briefly. `stop-first` removes the old task first, which guarantees only one version is live but creates a gap. Use `start-first` unless two versions genuinely cannot coexist.

Can I do zero-downtime deploys with one replica?

Yes, with `order: start-first`. Swarm temporarily runs two tasks even though `replicas: 1`, then converges. You need enough memory on the node for both, and no host-mode published port.

Should the health endpoint check the database?

Not in the container health check. If the database has a hiccup, a dependency-checking health endpoint marks every replica unhealthy at once and turns a slow query into an outage. Check the process, and monitor dependencies separately.

How do I roll back a bad deploy in Dokploy?

Set `failure_action: rollback` so Swarm does it automatically when the new tasks fail their monitor window. To roll back manually, run `docker service rollback <service>` on the host, then redeploy the known good image tag from the UI.

Sources

  1. Swarm service rolling updates Docker Primary source Accessed 12 Jun 2026
  2. Compose deploy specification Docker Primary source Accessed 12 Jun 2026
  3. Dockerfile HEALTHCHECK instruction Docker Primary source Accessed 12 Jun 2026
  4. Traefik Docker Swarm provider Traefik Labs Primary source Accessed 12 Jun 2026
  5. Dokploy documentation Dokploy Primary source Accessed 12 Jun 2026
4 min read

Part of a learning path

  • Docker intermediate

    Dokploy vs Coolify: Choosing a Self-Hosting Platform

    Both put a Heroku-style deploy UI on your own VPS, but on different container primitives. What that means for zero-downtime deploys, multi-server growth and the day the control plane breaks.

    6 min AI-assisted
  • DevOps intermediate

    How to Build a GitHub Actions Deployment Pipeline

    Test, build a container image, push it to GHCR and deploy it to a VPS over SSH — with the concurrency guard, environment approval and token scoping that keep the pipeline from becoming the weak point.

    4 min AI-assisted
  • Cloudflare advanced

    How to Run Cloudflare Tunnel in Front of Traefik

    Expose Docker services through Cloudflare with no inbound ports open — cloudflared plus Traefik, the forwarded-header setting that trips people up, and how to verify the firewall is closed.

    4 min