Back to Blog
5 min read

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.

Zero-Downtime Deploys with GitHub Actions + Coolify

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 1

The 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 --build per service, not a blanket down && 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 /health differentiation 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
  • /health checks real dependencies (DB, cache), not just process liveness
  • App handles SIGTERM and 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.