Zero-Downtime Deploys with GitHub Actions + Coolify
A practical GitHub Actions + Coolify deploy pipeline — build, health-check, and roll out with no dropped requests, plus the failure modes that actually bite in production.
Most "zero-downtime deploy" writeups stop at the happy path. Here's the pipeline I actually run — GitHub Actions building and pushing an image, Coolify redeploying it behind Traefik, with the health-check and rollback steps that matter once real traffic depends on it.
Why Coolify
Coolify sits on top of Docker + Traefik and gives you Heroku-style deploys on your own VPS — no vendor lock-in, no per-seat pricing. It handles the reverse proxy, TLS certificates (Let's Encrypt via Traefik), and container lifecycle, so the CI pipeline's only job is: build an image, hand it to Coolify, verify it's healthy.
That split — CI builds, Coolify deploys — is what makes zero-downtime possible without hand-rolled orchestration.
The Pipeline
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Trigger Coolify deploy
run: |
curl -X GET "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}"
- name: Wait for health check
run: |
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" https://your-app.com/health)
if [ "$code" = "200" ]; then
echo "Healthy after ${i} tries"
exit 0
fi
sleep 5
done
echo "Health check failed after 150s"
exit 1The health-check loop is the part people skip, and it's the part that actually gives you zero downtime: Coolify spins up the new container alongside the old one, and Traefik only cuts traffic over once the new container answers /health with a 200. If it never does, the old container just keeps serving.
The Health Check Endpoint Has To Mean Something
A health check that only returns 200 OK from an empty route tells you the process is running — not that the app works. Mine check the things that actually cause silent failures:
// health.ts
app.get('/health', async (req, res) => {
try {
await db.raw('SELECT 1')
await redis.ping()
res.status(200).json({ status: 'ok' })
} catch (err) {
res.status(503).json({ status: 'degraded', error: err.message })
}
})If the database connection pool didn't come up, or Redis is unreachable, this fails — and Traefik never routes traffic to that container. Without this, you can "successfully" deploy a container that's actually broken.
Nginx / Traefik Details That Matter
A few config details that silently break zero-downtime deploys if you skip them:
- Graceful shutdown: your app needs to catch
SIGTERM, stop accepting new connections, finish in-flight requests, then exit. Without this, in-flight requests get dropped the instant the old container is killed.
process.on('SIGTERM', async () => {
server.close(() => {
db.destroy().then(() => process.exit(0))
})
})-
Keep both containers briefly overlapping — Coolify/Traefik handle this by default, but if you're hand-rolling with plain Docker Compose, you need
docker compose up --no-deps --buildper service, not a blanketdown && up, which does cause a gap. -
Session/websocket stickiness: if your app holds long-lived connections (SSE, WebSockets), the old container needs to keep serving existing connections until they naturally close or time out, not get killed mid-connection. Traefik's default drain behavior handles this if you configure a shutdown grace period.
Rollback Is Just Redeploying an Older Tag
Because every build is tagged with the git SHA and pushed to GHCR, rollback is deploying the previous known-good tag — no separate rollback tooling needed:
# Point Coolify at a previous image tag and redeploy
curl -X GET "${COOLIFY_WEBHOOK_URL}?tag=<previous-sha>" \
-H "Authorization: Bearer ${COOLIFY_API_TOKEN}"Keeping the last 5-10 tags around in the registry is cheap insurance — the rollback path shouldn't require a rebuild.
What Actually Breaks This In Practice
- Long migrations run inside the deploy step. If a schema migration takes 90 seconds and your health-check timeout is 60, the deploy looks "failed" when it isn't. Run migrations as a separate step before the health-check loop starts, not inside the container's boot sequence.
- No
/healthdifferentiation between "starting" and "broken". A container that's still warming up a connection pool and one that crashed both look unhealthy for the first few seconds — give it a startup grace period before the check starts failing the deploy. - Telegram/Slack notifications on the deploy step, not the health-check result. I learned this the hard way — a "deploy succeeded" notification fired before the health check even ran meant a broken deploy still looked green in the log.
Checklist
- CI builds and tags images with the git SHA, pushes to a registry
- Deploy trigger is a single webhook call — Coolify (or your PaaS) owns the container swap
-
/healthchecks real dependencies (DB, cache), not just process liveness - App handles
SIGTERMand drains in-flight requests before exiting - Health-check loop with a real timeout gates the "deploy succeeded" notification
- Rollback is "redeploy the previous tag," tested at least once before you need it for real
Zero-downtime isn't one feature — it's the health check, the graceful shutdown, and the deploy trigger all agreeing on what "ready" means.