SYSTEM DESIGN · DESIGN PROBLEMS · PLATFORM AND ASYNC · MEDIUM · 39 OF 41

Design a CI/CD Pipeline: From Git Push to Safe Production Deploys

How to design a hosted CI/CD system: webhooks that start pipelines, YAML parsed into a DAG of jobs, runners that pull work with leases, ephemeral sandboxes and short-lived secrets, live log streaming, caching and test sharding for speed, immutable artifacts promoted from staging to production, and canary deploys that roll back on their own.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. A push, pull request, tag, schedule or button press triggers a pipeline. The pipeline is defined in a YAML file inside the repository, as jobs with dependencies. Jobs run in isolation, logs stream live, and the pass or fail status goes back to the pull request. Builds produce artifacts, and deploys support approvals, canaries and rollback.

Qualities. Feedback must be fast: a job should start within ten seconds, and a typical pull request should finish in about ten minutes. Jobs run other people's code, so isolation is a security requirement, not a nice-to-have. Secrets must never appear in logs or reach pull requests from forks. A lost runner should cause a retry, never a pipeline stuck forever.

Functional
  • trigger on push, PR, tag,
  • schedule, or by hand
  • pipeline defined in the repo
  • (YAML): stages, jobs, needs
  • run jobs in isolation
  • live logs, pass/fail status
  • back on the PR
  • store artifacts
  • deploy with approvals,
  • canary, rollback
Non-functional
  • fast: job starts in ≤ 10 s,
  • typical PR feedback ≤ 10 min
  • isolation: one job cannot see
  • another tenant's code
  • secrets never in logs, never
  • given to untrusted forks
  • reliable: a lost runner
  • means a retry, not a hang
  • out of scope: the git host

Ask scale

Scale, for a hosted service. A million pipeline runs a day with five jobs each is five million jobs, about sixty starts a second. With an average job of eight minutes, Little's law says around twenty-nine thousand jobs run at any moment, near ninety thousand at the weekday peak. Logs add five terabytes a day and artifacts fifty, so deduplication matters.

Back of the envelope
1M pipeline runs/day × 5 jobs
  = 5M jobs/day ≈ 60 starts/s

average job 8 min = 480 s
  concurrent ≈ 60 × 480 ≈ 29,000
  weekday peak ×3 ≈ 90,000 runners

logs: 1 MB/job → 5 TB/day
artifacts: 50 MB/run → 50 TB/day
  keep 30 days ≈ 1.5 PB (dedupe!)

Define API

The API. Signed webhooks from the git host start runs, and users can also start one by ref and commit. Clients read run status, follow live logs over server-sent events, cancel runs and approve deploys. Runners use their own protocol: they long poll to claim a job, then report state and extend their lease.

API
POST /v1/hooks/git  (signed)
POST /v1/repos/{r}/runs { ref, sha }
GET  /v1/runs/{id}
GET  /v1/jobs/{id}/logs?follow=1
     (server-sent events)
POST /v1/runs/{id}:cancel
POST /v1/deploys/{id}:approve

runner protocol:
POST  /v1/runner/claim (long poll)
PATCH /v1/jobs/{id} {state, lease}

Data model

Data model. Runs and jobs are relational, with clear states and relationships, so they live in SQL. Each job lists the jobs it needs, the runner labels it requires, and its lease. Logs are written as chunks to object storage. Artifacts are stored by the hash of their content, so identical outputs are stored only once.

Data model
runs (SQL)
  run_id, repo, sha, ref,
  trigger, status, created_at
jobs (SQL)
  job_id, run_id, name, needs[]
  labels, state, runner_id,
  lease_until, attempt

logs: chunks in object storage
artifacts: blobs by SHA-256
  (same content stored once)

High-level design

The high-level design. A webhook arrives; the pipeline service fetches the YAML at that commit, turns it into a graph of jobs and saves them. Jobs with no unfinished dependencies go onto the queue. A runner claims one, checks out the code, runs the steps, streams logs, uploads artifacts and reports the result.

Deep dive: the DAG

Deep dive one: the graph. Lint, unit and end-to-end tests run in parallel; build needs all three; deploy needs build. When a job finishes, the service checks each child: if all its needs passed, enqueue it; if any failed, skip it. A matrix expands one job into several, one per runtime version. And a new push to the same branch cancels the older run, which saves a surprising amount of compute.

Pipeline as a graph
lint ─┐
unit ─┼─→ build ─→ deploy-stg
e2e  ─┘          └→ deploy-prod
                     (approval)

job done → for each child:
  all needs passed? → enqueue
  any failed? → skip child
matrix: unit × {node18, node20}
new push on branch → cancel
  the older run

Deep dive: runners

Runners pull work instead of being pushed to. Each one long polls for jobs matching its labels, such as Linux with eight CPUs. Pulling means a company can run its own runners behind its firewall and still connect. While running, the runner holds a lease and heartbeats; if it disappears, the job is requeued. Per-organisation limits keep one tenant from taking every runner.

Pull, do not push
  • runners long-poll for jobs
  • matching their labels
  • → works behind firewalls
  • (self-hosted runners)
  • lease + heartbeat while
  • running; no heartbeat →
  • job requeued (attempt + 1)
  • per-org concurrency limits

Deep dive: isolation

Deep dive two: isolation. Every job is untrusted code. Run each one in a fresh micro virtual machine, such as Firecracker, that boots in a fraction of a second and is destroyed when the job ends, so nothing leaks to the next tenant. Plain containers share the host kernel, which is a weaker boundary. Add CPU, memory, time and network limits.

Secrets. Store them encrypted, inject them only when the job starts, and mask their values in the log stream. Pull requests from forks get no secrets at all, or anyone could print them. Better still, let jobs reach the cloud through OIDC: the job receives a token valid for minutes, scoped to one repository and branch, so there is no long-lived key to steal.

Sandboxing
  • each job: new micro-VM
  • (e.g. Firecracker) or VM
  • no state left behind
  • for the next tenant
  • containers alone share the
  • host kernel → weaker
  • network egress rules,
  • CPU/memory/time limits
Secrets
  • stored encrypted (KMS)
  • injected as env at job start
  • masked in log output
  • fork PRs: no secrets at all
  • cloud access via OIDC:
  • job gets a token valid for
  • minutes, scoped to one repo
  • and branch → no long-lived
  • keys to steal

Deep dive: logs

Logs. The runner sends numbered chunks every second or every sixty-four kilobytes. The log service pushes them to anyone watching, over server-sent events, and appends them to object storage. Because chunks are numbered, a viewer who reconnects asks for everything after chunk forty-one and sees no gaps. Old logs expire after ninety days.

Live logs
  • runner sends numbered chunks
  • every ~1 s or 64 KB
  • log service:
  • fans out to live viewers
  • (server-sent events)
  • appends to object storage
  • viewer reconnects with
  • "from chunk 41" → no gaps
  • logs kept 90 days

Deep dive: speed

Deep dive three: speed. Most build time repeats work. Cache dependencies keyed by the hash of the lockfile, and container layers by digest. Keep a warm pool of booted machines so jobs start in seconds. Split a forty-minute test suite across eight runners for five minutes. In a monorepo, build and test only what the change actually affects.

Making it fast
  • cache by content key:
  • deps: hash(lockfile)
  • image layers: by digest
  • warm pool of booted VMs
  • → start in seconds
  • shard tests: 40 min suite
  • ÷ 8 runners ≈ 5 min
  • monorepo: build only what
  • the change affects

Deep dive: deploys

Deploys. Build once and promote the same image digest through staging, a canary and production, so what you tested is exactly what you ship. The canary takes five percent of traffic for about fifteen minutes while the system compares error rate and p ninety-nine against the current version, rolling back on its own if they are worse. Lock each environment to one deploy at a time. See Concepts episode 39.

Safe delivery
  • build once → immutable image
  • promote the same digest:
  • staging → canary → prod
  • canary: 5% for 15 min
  • error rate, p99 vs baseline
  • worse → automatic rollback
  • one deploy per environment
  • at a time (lock)
  • approval gate for prod

Trade-offs

Failure modes. Webhooks get lost, so also poll the git host for new commits now and then. A dead runner means a lease expiry and a retry. Flaky tests are the biggest real pain: retry once, but track each test's flake rate and quarantine the worst. If the cache is down, builds are slower but still correct. A bad deploy is caught by the canary.

Monitor queue wait at p ninety-five for each runner label, run duration, success and flake rates, cache hit rate, runner utilisation and cost, and how often deploys roll back. At ten times the scale, shard the pipeline database by organisation, add regional runner pools near the code, and autoscale runners on queue depth.

Failure modes
  • webhook lost → poll the git
  • host for new commits
  • runner dies → lease expires,
  • job retried
  • flaky test → retry once,
  • track flake rate per test
  • cache down → slower, not
  • broken
  • bad deploy → canary catches
  • it, auto rollback
Monitor and grow
  • monitor:
  • queue wait p95 per label
  • run duration, success rate
  • flake rate, cache hit rate
  • runner utilisation and cost
  • deploy rollback rate
  • at 10x:
  • shard pipeline DB by org
  • regional runner pools
  • autoscale on queue depth

Say it like this

In the interview: a webhook turns the repository's YAML into a graph of jobs, and ready jobs go to a queue by label. Runners pull work, hold leases and heartbeat, inside a fresh micro-VM per job. Secrets are masked, short-lived and never given to forks. Logs stream as numbered chunks. Caching, warm pools and test sharding keep it fast, and one immutable artifact is promoted through a canary.

Interview summary
  • webhook → YAML → job DAG
  • ready jobs → queue by label
  • runners pull, lease, heartbeat
  • fresh micro-VM per job
  • secrets: masked, scoped,
  • OIDC, none for forks
  • logs: numbered chunks → SSE
  • cache + warm pool + sharding
  • build once, promote digest,
  • canary + auto rollback

Interview questions

How do you model a pipeline internally?

As a directed acyclic graph of jobs parsed from the repository's YAML; when a job finishes, each child whose dependencies have all passed is enqueued, and children of failed jobs are skipped.

Why should runners pull jobs instead of the server pushing them?

Pulling with long polls lets self-hosted runners behind firewalls connect outbound only, naturally balances load because idle runners ask for work, and pairs well with leases for detecting lost runners.

How do you isolate untrusted builds from each other?

Run each job in a fresh, short-lived micro-VM or VM that is destroyed afterwards, with resource and network limits, because containers alone share the host kernel and are a weaker boundary.

How do you protect secrets in CI?

Encrypt them at rest, inject them only at job start, mask them in logs, never expose them to pull requests from forks, and prefer short-lived OIDC tokens scoped to a repository and branch over long-lived cloud keys.

How would you make pipelines faster?

Cache dependencies and image layers by content key, keep a warm pool of booted runners, shard long test suites across parallel runners, cancel superseded runs, and in monorepos build only affected targets.

Why build an artifact once and promote it instead of rebuilding per environment?

Promoting the same immutable image digest guarantees that production runs exactly what was tested in staging, while rebuilding can pull different dependencies and produce a different binary.

How does a canary deploy decide to roll back?

It sends a small share of traffic to the new version and compares error rate and latency against the current version for a set period, rolling back automatically if the new version is significantly worse.

How do you stream live build logs to many viewers?

Runners send numbered log chunks to a log service that fans them out over server-sent events and appends them to object storage, so reconnecting viewers resume from the last chunk number without gaps.