Back to projects
November 2026

Sling

Zero-Downtime Blue/Green Deploy Orchestrator for a Single VPS

Go · Docker · Caddy (Admin API) · SQLite · GitHub Webhooks (HMAC-SHA256) · Let's Encrypt (ACME) · File Locks · GitHub Actions

Highlights

  • Push a commit and it goes live with zero downtime, or rolls itself back
  • Health gate: 3 consecutive healthy /health probes before a container sees traffic
  • Post-switch probe requires X-Sling-Upstream to name the new container, not just any 200
  • 30s grace window with automatic rollback after 3 failed probes in a row
  • HMAC-signed webhooks with a latest-push-wins queue (one running, one waiting)

System Overview

Sling is a small self-hosted deploy orchestrator written in Go. Given a git repo and an exact 40-character commit SHA, it builds a Docker image, starts the new version beside the old one, proves it is healthy, atomically switches a Caddy reverse proxy over to it, and keeps watching. If anything fails along the way, traffic goes back to the previous version.

The Problem & Architectural Rationale

Deploying to your own server usually means SSH, git pull, docker compose up --build, and hope. The old container stops before the new one is ready (downtime), nothing verifies the new release before users hit it, rollbacks are manual and stressful, and moving branch names mean the server can end up on a different commit than intended. Hosted PaaS platforms solve this, but cost money and run your app on their infrastructure.

Architecture & Components

1. Build: exact commit pinning

Only full 40-hex SHAs are accepted. After git checkout --detach, Sling compares git rev-parse HEAD with the requested SHA, then builds the image paas-app:<sha>. Building has no effect on users; redeploying the commit that is already live is refused before any container is touched.

2. Release: health gate before traffic

The candidate container runs beside the live one on sling-net with 256 MB / 0.5 CPU limits and a loopback-only port. It must return 3 healthy /health responses in a row directly; a crash on boot fails the gate immediately, so a broken build never gets traffic.

3. Atomic Caddy cutover & verification

The upstream is swapped in one step through Caddy's admin API (POST /load). Sling then probes through the proxy and requires the X-Sling-Upstream header to name the new container, proving the new version is the one actually answering.

4. Grace window & automatic rollback

For 30 seconds after the switch, Sling keeps probing. Three failures in a row point the proxy back at the previous version. The old container is stopped gracefully (SIGTERM, 10s drain) and its image is kept, so sling rollback can restore it in seconds with no rebuild.

5. Webhook server & deploy queue

sling serve verifies HMAC-SHA256 signatures with constant-time comparison, checks repo and branch, and takes the clone URL from its own config, never the payload. One worker, one waiting slot: a newer push replaces the waiting one, and an in-flight deploy always finishes, even during shutdown.

6. History, locking & HTTPS

An in-process mutex plus a cross-process file lock allows one deploy per host. Deploy history persists in SQLite, with crash detection that marks deploys from dead processes as interrupted. With SLING_DOMAIN set, Caddy obtains and renews Let's Encrypt certificates and forwards only /webhook to Sling.

Core Implementation

GO
// A deploy is two halves joined only by an image tag:
//
//   build   : source code -> image paas-app:<sha>   (no effect on users)
//   release : image -> live, safely                 (all the risk, all the checks)
//
// A rollback is release alone, on an image that already exists, so it
// gets every health check and the grace window without a second copy.
func Run(d *Deployment) error {
    return locked(d, func(ctx context.Context) error {
        if !shaPattern.MatchString(d.CommitSHA) {
            return fmt.Errorf("invalid commit SHA %q: must be 40 lowercase hex characters", d.CommitSHA)
        }
        if err := ValidateRepoURL(d.RepoURL, d.AllowLocalRepo); err != nil {
            return err
        }
        if err := prepare(ctx, d); err != nil {
            return err
        }
        // Cheap early exit: don't spend minutes building a commit that is already live.
        if err := refuseIfLive(ctx, d.CommitSHA); err != nil {
            return err
        }
        if err := build(d); err != nil {
            return err
        }
        return release(ctx, d)
    })
}

The deploy pipeline: build and release joined only by an image tag

Outcomes & Results

  • A crash-on-boot commit fails the health gate and never receives traffic; v1 keeps serving.
  • A release whose health starts failing after cutover is rolled back automatically within the grace window.
  • Rollbacks reuse the kept image and pass through every health check, with no rebuild.
  • A config mistake can't silently reconfigure a live site: mismatched proxy settings are refused before anything changes.