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:
- The new container reports healthy only when it can serve requests.
- Swarm waits for that signal before routing traffic to it.
- The old container keeps serving until the new one is ready.
- 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:
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:
// 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):
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: 3What 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 amonitor: 10swill be declared healthy.failure_action: rollback— revert automatically. The default ispause, which leaves you half-deployed and needing a manualdocker service update --forceto 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.
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=3000This 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:
docker service ps "$(docker service ls --filter name=app -q)" --no-truncNAME 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 agoThe update state is machine-readable, which is what you want in CI:
docker service inspect app --format '{{json .UpdateStatus}}' | jq{
"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.
#!/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"non-200 responses: 0If 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:
services:
app:
stop_grace_period: 30sCommon 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.