system-design

Design a CI/CD Platform (GitHub Actions / Jenkins / GitLab CI)

Difficulty: Tier 2 Asked at: GitHub, GitLab, Amazon, infra/platform teams Time budget: 45 min

A CI/CD platform runs your tests and deploys your code on every push. Under the hood it’s a distributed job-execution system: take a pipeline definition, schedule its stages onto a fleet of workers, run untrusted code in isolated environments, stream logs, and enforce dependencies between steps. It combines the job scheduler, queues, and a strong isolation/security angle (you’re running arbitrary user code).

Prerequisites: Design a Job Scheduler, Background Jobs, Deployment & Infra


1. Requirements

Functional:

Non-functional:

Out of scope: the git hosting itself, the deployment targets’ internals.


2. Estimation


3. High-level design

flowchart TB
    Trigger[Git push / PR / schedule] --> Orch[Pipeline Orchestrator<br/>parse config, build DAG]
    Orch --> Q[[Job queue]]
    Q --> Runner1[Runner / Worker<br/>isolated sandbox]
    Q --> Runner2[Runner / Worker]
    Runner1 -->|logs stream| LogSvc[Log streaming]
    Runner1 -->|status| Orch
    Orch --> Status[Status / checks back to git]
    Cache[(Artifact + dependency cache)] --> Runner1
    Secrets[(Secret store)] --> Runner1

Orchestrator parses the pipeline config into a DAG of jobs (respecting dependencies), enqueues ready jobs, tracks completion, and enqueues the next stage. Runners pull jobs, execute them in isolated sandboxes, stream logs, and report status. Artifacts/caches and secrets are provided to jobs.


4. Deep dives

4a. The pipeline as a DAG

🚨 A pipeline is a directed acyclic graph of jobs. Stages have dependencies (test needs build; deploy needs test). The orchestrator schedules a job only when its dependencies succeed, runs independent jobs in parallel, and fails fast (a failed dependency skips dependents). This DAG scheduling — with fan-out (parallel jobs) and fan-in (a stage waiting on several) — is the core control logic. It’s the job scheduler with dependencies.

4b. Isolation — running untrusted code safely

🚨 The security heart. User pipelines run arbitrary code; it must not compromise the host, other tenants’ jobs, or secrets. Isolation options (increasing strength):

4c. Scheduling onto the runner fleet

Match jobs to runners by required resources/labels (a GPU job → GPU runner; Linux vs Windows). Autoscale the fleet on queue depth (spin up runners for a burst, tear down when idle — CI load is spiky). Fair scheduling across tenants so one org’s huge pipeline doesn’t starve others. Queue time is a key UX metric.

4d. Log streaming

Developers watch logs live. Runners stream job output (chunked) to a log service that fans out to watching browsers (WebSocket/SSE) and stores logs durably for later viewing. Handle large, fast log output without overwhelming storage or the UI (buffer, truncate absurd output).

4e. Caching & artifacts

Re-downloading dependencies every run is slow; cache them (keyed by lockfile hash) in object storage and restore per run. Artifacts (build outputs) produced by one stage are stored and passed to dependent stages. This caching is often the biggest CI speed win. (Object Storage)

4f. Reliability & secrets


5. Bottlenecks & scaling further

  1. Job execution load → queue + autoscaling runner fleet.
  2. Isolation/security → containers/microVMs, ephemeral per-job environments, least privilege.
  3. DAG scheduling → orchestrator runs independent jobs in parallel, respects dependencies.
  4. Slow builds → dependency/artifact caching (often the biggest win).
  5. Fairness → per-tenant scheduling/quotas so one org can’t starve others.
  6. Log volume → streamed, buffered, durably stored with limits.

6. Trade-off summary

Decision Chosen Alternative Why
Isolation Ephemeral containers/microVMs per job Shared runners Untrusted code must not leak/compromise
Pipeline model DAG with parallel + dependencies Linear script Parallelism + correct ordering + fail-fast
Fleet Autoscaling on queue depth Fixed pool CI load is spiky; balance cost vs queue time
Speed Dependency/artifact caching Fresh every time Biggest build-time win
Scheduling Fair per-tenant FIFO One org shouldn’t starve others

7. Follow-up questions

How do you safely run arbitrary untrusted user code? By isolating every job in an ephemeral, sandboxed environment with least privilege, so a malicious or buggy pipeline can't affect the host, other tenants' jobs, or secrets. Each job runs in a fresh container (resource- limited via cgroups and namespaced) or, for stronger multi-tenant isolation, a lightweight microVM like Firecracker that gives VM-grade separation at near-container speed. The environment is ephemeral — created for that one job and destroyed afterward — so no state, credentials, or compromise leaks between runs, and a job that's exploited is thrown away when it finishes. On top of isolation you apply least privilege: jobs receive only the secrets and permissions they actually need, secrets are injected at runtime and masked from logs to prevent exfiltration, and untrusted contexts (like pull requests from forks) get further restricted access. The combination — per-job ephemeral sandboxes, strong isolation boundaries, and scoped least-privilege credentials — is what lets a shared platform run everyone's arbitrary code without one tenant endangering another or the platform itself. Isolation is the defining concern of a CI/CD system precisely because running untrusted code is its core function.
Why model a pipeline as a DAG rather than a linear list of steps? Because real pipelines have both parallelism and dependencies that a linear list can't express efficiently or correctly. Some jobs depend on others (deploy needs tests to pass, tests need the build), while others are independent and should run simultaneously (lint, unit tests, and a security scan can all run at once). A directed acyclic graph captures exactly this: edges encode "must run after," so the orchestrator runs a job only when its dependencies have succeeded, executes independent jobs in parallel to minimize total time, waits at fan-in points where a stage needs several predecessors, and fails fast by skipping dependents when a dependency fails. A linear script would force everything to run sequentially even when it needn't (slow) or would lack the structure to know what depends on what (incorrect ordering, no fail-fast). The DAG is the natural model for "run these jobs respecting their dependencies while maximizing parallelism," which is the core scheduling logic of CI/CD — essentially the job-scheduler problem with dependency edges.
CI load is spiky. How do you size the runner fleet? By autoscaling the runner fleet based on queue depth rather than provisioning a fixed pool. CI demand is bursty — quiet overnight, then a flood when the workday starts or a big merge triggers many pipelines — so a fixed fleet is either wastefully idle most of the time or too small during peaks, leaving developers waiting in a long queue. Instead you watch the job queue and scale runners up when it grows (spinning up more workers, often on cheap/spot capacity) and tear them down when it drains, matching capacity to real-time demand. You schedule jobs onto runners by matching required resources and labels (a GPU or Windows job goes to a capable runner), and you enforce fair per-tenant scheduling and quotas so one organization's giant pipeline can't monopolize the fleet and starve everyone else. Queue time is a key user-experience metric, so the goal is to keep it low during bursts without paying for idle capacity in the troughs — which elastic, queue-driven autoscaling with fair scheduling delivers.
What's usually the biggest lever for making builds faster? Caching dependencies and reusing artifacts between runs. A large fraction of build time is often spent re-downloading and re-installing the same dependencies (packages, modules, base images) on every run, and recompiling unchanged code, so caching those — keyed by a hash of the lockfile or inputs so the cache is valid — and restoring them at the start of a job eliminates most of that repeated work. Similarly, artifacts produced by one stage (compiled binaries, built images) are stored and passed to dependent stages rather than rebuilt, and incremental builds reuse prior outputs where inputs haven't changed. Combined with running independent jobs in parallel via the DAG, caching typically gives the biggest wall-clock reduction because it attacks the repeated, redundant work that dominates naive pipelines. The cache and artifacts live in fast object storage and are scoped per repo/branch to stay correct. So while parallelism and bigger runners help, dependency/artifact caching is usually the single highest-impact optimization.

8. What junior / mid / senior answers look like


Further reading