System Design Problem

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

Commonly Asked By:GitLabGitHubCircleCIJenkinsGoogle

Interview Setup

Interview Prompt

Design a CI/CD platform (GitLab CI / GitHub Actions style) for 5M pipelines per day, 500K peak concurrent jobs, and 30-minute average job duration. Support pipeline DAGs (stages, jobs, matrix builds, needs/dependencies), an autoscaling ephemeral runner fleet, work queues with fair multi tenant scheduling, artifact storage with Docker layer caching, and secure secrets injection at runtime. Pipelines are triggered by git push/PR webhooks from a code hosting platform.

Clarifying Questions (ask before designing)

QuestionWhy it matters
Is git hosting in scope, or only CI/CD?Code Hosting Platform covers Git storage, push/pull protocols, and pull requests. This problem focuses exclusively on CI/CD architecture, consuming push and pull request events via webhooks rather than redesigning Git storage engines.
Hosted runners vs self hosted agents?Hosted ephemeral VMs represent the default scale path for 500K concurrent workloads. Self-hosted agents register long lived connections and poll for jobs, which is simpler but leaves sandboxing and isolation to the customer.
Exactly-once or at least once job execution?At least once dispatch is paired with idempotent deployment effects. A runner can crash after performing work but before the scheduler observes completion, so arbitrary runner execution cannot provide true exactly once execution semantics end to end. Exactly once effects can still be achieved for sinks that support transactions or idempotency.
How are secrets provided to jobs?Integrate with a dedicated Secrets Manager to issue short lived dynamic credentials and OIDC-to-cloud IAM federated tokens. Secrets must never be stored in pipeline YAML or runner base images.
What breaks first at 500K concurrent jobs?Runner fleet capacity and fair share scheduling saturate before the API tier. Sizing the compute pool, queue depth, and autoscaling signals takes precedence over optimizing YAML parsing.

Scope

In scope

  • Pipeline definition (YAML) compilation into executable DAG
  • Webhook triggers from push and pull request events
  • Job scheduling with stages, needs, and matrix fan out
  • Ephemeral runner fleet with autoscaling and isolation
  • Work queue (Kafka/Redis) with priority and fair share scheduling
  • Artifact store (S3) and Docker layer caching
  • Secrets injection at runtime via secrets management and OIDC to cloud providers
  • Job leasing, heartbeat, retry, cancellation, and stale attempt protection

Out of scope (state explicitly)

  • Git object storage and receive-pack internals (Code Hosting Platform serves as the clone source)
  • Issue tracking, code review UI, and merge conflict resolution (Code Hosting Platform)
  • Building a custom container orchestrator from scratch (leverage Kubernetes under the hood)
  • Full secrets manager design (integrate via runtime APIs)

Functional Requirements

Start by asking your interviewer whether git hosting is in scope, pointing to Code Hosting Platform (GitHub) as the boundary. Clarify ephemeral runner isolation, DAG scheduling with needs: dependencies, secrets injection via Secrets Manager & KMS, and fair share scheduling across tenants.

  • Pipeline definition: Parse .gitlab-ci.yml or GitHub Actions workflow YAML into an executable DAG of jobs with stages, needs: dependencies, and if: conditions.
  • Triggers: Start pipelines on git push, pull request, tag, schedule (cron), and manual API triggers by consuming webhook events from Code Hosting Platform (GitHub).
  • Matrix builds: Fan out one job definition across an axis (OS, language version, shard index).
  • Job execution: Dispatch jobs to ephemeral runners, stream logs, and support cancellation, retry, and timeout policies.
  • Runner fleet: Hosted ephemeral VMs and containers with autoscaling, alongside optional self hosted agent registration.
  • Artifacts: Upload and download build outputs between jobs and after pipeline completion backed by object storage.
  • Cache: Docker layer and dependency caches (npm, Maven) shared across runs per repository and organization.
  • Secrets and variables: Inject runtime secrets via Secrets Manager & KMS, supporting OIDC federation to cloud IAM without stored long lived keys.
  • Status reporting: Report commit check status back to Code Hosting Platform (GitHub) with pass or fail outcomes per job and pipeline.
  • Multi-tenant fair scheduling: Per-organization concurrency limits, priority lanes, and queue fairness across tenants.

Non-Functional Requirements

Your interviewer will stress-test runner fleet sizing using Little's Law, since 500K concurrent jobs at a 30-minute average duration represents the dominant cost line rather than the control plane. In addition, handling secrets in pull requests from forks and enforcing idempotent deployments represent primary security and operational concerns.

  • Scale: 5M pipelines/day, ~25M job runs/day, and 500K peak concurrent jobs.
  • Isolation: Each job executes within an isolated, single use sandbox designed to prevent cross job filesystem, network, or credential leakage.
  • Reliability: At least once dispatch paired with idempotent side effects following Idempotency and Exactly-Once Effects.
  • Latency: Webhook to first job queued latency < 5s p99, and P0 queue wait time < 60s p99.
  • Availability: 99.9% uptime for the job dispatch path, since CI/CD outages block deployment releases across the organization.
  • Security: Zero secret injection into fork pull requests from untrusted contributors, automated log masking, short lived OIDC federated cloud credentials, and environment scoped secret policy for trusted jobs.
  • Observability: Distributed tracing per pipeline via Observability and Distributed Tracing, combined with metrics for queue depth, runner utilization, and success rates per repository.

Capacity Estimations

Runner fleet RAM-hours dominate infrastructure costs, where Little's Law connects concurrent jobs, duration, and dispatch throughput. A log volume of 125 TB per day must stream directly to object storage, ensuring the control plane never proxies heavy log payloads.

MetricCalculationValue
Pipelines triggered / dayGiven5M
Avg jobs per pipelineStages + matrix (~5 jobs typical)~5
Total job executions / day5M x 5~25M
Avg job durationGiven30 min
Peak concurrent jobsPlanning capacity target500K
Implied average concurrency if 25M jobs/day each run for 30 min(25M ÷ 86400) x 1800s~521K (consistency check)
Peak job completion rate (Little's Law)500K ÷ 1800s~278 jobs/sec
Avg job dispatch rate25M ÷ 86400~289 jobs/sec
Peak dispatch rate (3x avg)289 x 3~870 jobs/sec
Runner fleet at peak1 ephemeral runner per concurrent job~500K VMs/containers
Warm pool (pre provisioned)~10% of peak for latency~50K idle runners
Log volume / day25M jobs x 5 MB avg logs~125 TB/day
Artifact storage (7-day retention)20% jobs x 100 MB x 25M x 7 days~3.5 PB logical footprint before compression
Docker layer cache100K tenants x 500 GB hot per large tenant~50 PB upper bound
Pipeline metadata (PostgreSQL)25M jobs x 2 KB rows + DAG edges~50 GB/day logical row volume (partitioned, with indexes/WAL adding write amplification)
Kafka dispatch payload throughput870 dispatch/sec x 4 KB payload~3.5 MB/sec peak ingress

The planning inputs are intentionally conservative rather than a mathematically self-consistent steady-state model. At 25M jobs per day and a 30-minute average duration, Little's Law implies roughly 521K concurrent jobs under a steady-state assumption, slightly above the stated 500K planning peak. Treat 500K as the operational fleet target and size burst capacity and effective average duration accordingly. The daily volume, average duration, and peak concurrency are therefore planning inputs rather than a claim that the system remains in steady state at every moment.

The runner fleet represents the dominant cost driver at 500K concurrent executions across a 30-minute average duration. The stated ~870 jobs/sec dispatch rate is modest relative to the runner workload, and the dispatch design spans 224 Kafka partitions across P0, P1, and P2 lanes. Actual Kafka and PostgreSQL limits still require benchmarking and sharding as state transitions grow. Log ingestion at 125 TB/day streams directly to S3 through runner held presigned URLs so control plane instances do not proxy log payloads. Artifact storage applies a 7-day hot retention tier followed by Glacier lifecycle transitions to manage the ~3.5 PB logical retained footprint.

Architecture Diagram

Establish the system boundary with Code Hosting Platform (GitHub) early: code hosting publishes git.push_event notifications, and this platform consumes them. The webhook ingestor compiles pipeline YAML into an execution DAG and passes ready jobs to a scheduler designed around Distributed Job Scheduler patterns.

Runners are strictly ephemeral, allocating one isolated VM or container per job that terminates completely upon teardown. Use hardened VM or microVM isolation for untrusted multi tenant execution when stronger isolation than a shared kernel provides is required. Runners fetch scoped credentials from Secrets Manager & KMS at initialization via OIDC tokens, stream logs and upload artifacts directly to Blob Storage (S3) using presigned URLs, and report terminal status back so the code hosting platform can update commit check statuses.

The platform operates across four decoupled stages: webhook ingestion and YAML compilation, DAG scheduling with PostgreSQL authoritative state plus Redis dependency counters, prioritized Kafka queues with multi tenant fair sharing, and an autoscaling runner fleet with atomic lease management. Durable state changes flow through the PostgreSQL outbox before asynchronous publication. While job dispatch follows at least once semantics, externally visible deployment effects require strict deduplication keys as described in Idempotency and Exactly-Once Effects.

Loading...

In the room

Size runner capacity before discussing YAML syntax: "500K concurrent runners at a 30-minute average duration defines the infrastructure fleet, not PostgreSQL." If the interviewer probes duplicate deployments, explain atomic lease acquisition via Redis SETNX combined with idempotent execution tokens on deployment steps.

Pipeline DAG Execution

Loading...

Runner Job Lifecycle

Loading...

Component Deep Dives

1. Webhook Ingestor and VCS Integration

The pipeline spanning webhook ingestion, compilation, DAG scheduling, and runner execution forms the primary backbone. Matrix fan out and multi tenant fair sharing provide the deeper staff level scheduling discussion.

Push events from Code Hosting Platform (GitHub) are processed idempotently, ensuring duplicate webhook deliveries never create duplicate pipeline runs.

Git push triggers pipeline (boundary between VCS and CI/CD):

  1. Developer: git push origin feature-branch
  2. Code hosting Git service (Gitaly for GitLab): receive-pack updates the ref and triggers the repository hook
  3. Code hosting event bus (Kafka in this design): publish git.push_event { repo_id, ref, sha, actor, event_type }
  4. CI/CD Webhook Ingestor consumes push_event:
     a. Validate provider signature, issuer metadata, timestamp, and delivery ID before parsing the payload
     b. Reject duplicate delivery IDs using the durable webhook deduplication record, or return the existing pipeline ID for an already processed delivery
     c. Match branch filter (e.g. refs/heads/main, refs/heads/feature/*)
     d. Load pipeline config from repo (.gitlab-ci.yml / .github/workflows/ci.yaml)
     e. Compile YAML into a DAG of jobs (stages, needs, matrix, if: conditions)
     f. In one PostgreSQL transaction, insert pipeline_run, job rows, dependency rows, the webhook delivery dedup record, and outbox events
     g. Outbox publisher emits each root job as a durable dispatch event to the priority lane selected by trigger and job priority
  5. Scheduler dispatches when a runner is available and dependencies are satisfied
  6. Outbox publisher emits durable pipeline and job events to Kafka, while status callbacks PATCH pipeline status back to the VCS commit checks API

PR events: pull_request.opened/synchronize trigger the same flow with the merge commit SHA
Manual: POST /repos/{id}/pipelines { ref, variables }: bypasses webhook ingestion but enters the same compiler, persistence, and scheduling path

Idempotent ingestion uses the provider delivery ID as the webhook deduplication key and persists that record in the same PostgreSQL transaction that creates the pipeline. This prevents retries from creating a second pipeline. The transactional outbox then publishes the resulting events to Kafka without requiring the database write and Kafka publish to be one distributed transaction.

2. Pipeline Compiler (YAML to DAG)

The compiler translates pipeline YAML into an executable directed acyclic graph at trigger time, verifying that all needs: dependencies form a valid acyclic structure before enqueueing jobs.

  • Parse: Loads configuration from the repository at the trigger commit SHA via internal VCS APIs rather than public git clone operations.
  • Validate: Confirms acyclic needs: graph structures, resolves registered runner labels, and verifies that requested secret paths exist in the configured secret scope.
  • Expand: Evaluates matrix build axes, expands included configuration templates, and computes environment variable inheritance.
  • Persist: Records pipeline_runs, jobs, dependency rows, and webhook deduplication state in PostgreSQL before emitting durable outbox events.
  • Enqueue: Moves jobs with zero unsatisfied prerequisites to the READY state and publishes them to the Kafka dispatch topic.

3. DAG Scheduler Patterns ⭐

PostgreSQL stores the authoritative DAG state, while Redis dependency counters act as a derived fast path. Parent completion decrements a child counter atomically, and repair jobs can rebuild counters from persisted dependencies after Redis loss. Pipeline stages enforce sequential synchronization barriers between execution tiers.

  • State machine: Manages transitions through PENDING, READY, QUEUED, RUNNING, and terminal states (SUCCEEDED, FAILED, SKIPPED, CANCELLED).
  • Dependency resolution: Executes a guarded Redis DECR deps:{child} after an idempotent parent completion event, automatically enqueueing the child when the counter reaches zero. Durable job state remains authoritative for recovery and reconciliation.
  • Stage barriers: Applies implicit dependencies across stages for jobs without explicit cross stage needs, ensuring those jobs wait for the preceding stage before becoming eligible.
  • Failure propagation: Marks dependent children as SKIPPED when a parent fails, unless the job explicitly sets allow_failure: true.
  • Concurrency groups: Enforces concurrency: deploy-prod rules to guarantee only one active job runs per group per branch, managed through distributed locking.

4. Work Queue and Job Leasing ⭐

Priority lanes and lease based execution limit duplicate work by using durable dispatch records, a Redis duplicate publication guard, and atomic leases on worker claims, leveraging principles from Message Queues Fundamentals. The design still assumes at least once delivery, so externally visible side effects require idempotency.

Topic: pipeline-events
  Partitions: 256 (partition by repo_id for ordering per repository)
  Retention: 30 days
  Events: pipeline.created, pipeline.started, job.queued, job.started, job.completed,
          job.failed, job.cancelled, artifact.uploaded
  Payload: { event_id, tenant_id, repo_id, pipeline_id, job_id, attempt_id?, status, trace_id }
  Producer: transactional outbox publisher from PostgreSQL state changes
  Idempotency: event_id is unique for publication and consumers tolerate redelivery
  Consumer groups: lifecycle, webhook fanout, metrics indexer, and audit consumers use independent groups

Topic: jobs.dispatch (priority lanes)
  jobs.dispatch.p0: merge queue, protected branch (32 partitions, key=repo_id)
  jobs.dispatch.p1: default push/PR (128 partitions, key=tenant_id)
  jobs.dispatch.p2: scheduled/nightly (64 partitions, key=tenant_id)

Dispatch envelope: { event_id, job_id, attempt_id?, tenant_id, repo_id, priority, trace_id }
Consumer: Runner Fleet Manager: partition consumers share work, while scheduler quotas provide tenant isolation
Consumer: Webhook fanout: notify code hosting integrations, Slack, and deployment APIs
Consumer: Metrics indexer: track queue wait time and success rate per repo in metrics storage

DLQ: jobs.dispatch.dlq: alert on any message and enable manual replay via console
  • Dispatch guard: Uses SET job:dispatch:{id} NX EX 60 only as a duplicate publication guard. The PostgreSQL outbox record remains authoritative and is retried until Kafka acknowledges publication, so a scheduler crash after acquiring the Redis guard but before Kafka publication does not lose the dispatch.
  • Execution lease: Runners claim jobs via atomic SET job:lease:{id} NX EX 1800 commands, then record the claim against the authoritative attempt using a conditional database update. If the database claim fails, the runner releases or abandons the Redis lease and does not execute the job. Every heartbeat and terminal status update includes the current attempt and lease token, and the scheduler accepts the update only when those values still match the authoritative attempt. The durable terminal state is committed before lease cleanup is attempted.
  • Reaper daemon: Scans active jobs with expired leases every 30 seconds. Before creating a replacement attempt, it checks the authoritative PostgreSQL state. A CANCEL_REQUESTED job becomes CANCELLED instead of being retried. Other expired attempts are requeued or failed according to retry policy.

5. Fair Share Queue Manager ⭐

Weighted fair share scheduling prevents high volume tenants from monopolizing the runner pool, while dedicated merge queue lanes reserve capacity for time sensitive integration checks.

Multi-tenant fair scheduling:

Per-tenant limits (PostgreSQL policy + Redis counters):
  max_concurrent_jobs: 500 (enterprise: 5000)
  max_queue_depth: 2000
  weight: 1 (free) | 10 (paid) | 100 (enterprise)

Dispatch ordering:
  separate priority lanes select P0, P1, or P2 first
  within each tenant lane, encode virtual service time, enqueue_time, and job_id into a deterministic Redis ZSET score
  aging reduces effective virtual service time as wait time increases

Global coordinator (every 1s, driven by the active tenant set rather than a scan of all 100K tenants):
  1. Compute fair share credits from active tenant weights
  2. fair_share_slot = total_available_runners / sum(active_tenant_weights)
  3. Carry fractional credits across rounds, convert them into integer admissions, and redistribute unused slots among eligible tenants
  4. Admit jobs up to each tenant's concurrency and queue limits
  5. P0 jobs use a reserved 20% runner capacity budget, though idle reserved capacity may be loaned to P1/P2
  6. Reconcile Redis running counters against authoritative PostgreSQL job states after failover

Starvation: if job.wait_time > 2h and priority=P2: promote to P1

6. Runner Fleet Manager

Autoscaling policies track Kafka consumer lag, while pre provisioned warm pools eliminate cold start delays for latency-critical feedback loops.

  • Ephemeral sandboxes: Provisions dedicated, single use Kubernetes pods or virtual machines per job that are destroyed immediately upon task completion.
  • Warm pool: Maintains approximately 10% of peak capacity pre warmed to absorb burst traffic without container initialization latency.
  • Autoscaling: Drives horizontal pod autoscaling on runner deployments based on Kafka consumer lag and queue depth.
  • Self-hosted agents: Enables long lived customer hosted agents to register via secure tokens and poll POST /v1/runners/claim, delegating compute costs and network isolation to the user.
  • Target labels: Matches job requirements such as runs-on: [linux, gpu, large] against appropriate runner pools.

7. Runtime Secrets Injection ⭐

Secrets are never baked into runner images or stored in repository YAML. Runners obtain short lived credentials from Secrets Manager & KMS via OIDC federation. The runner agent performs best effort process cleanup on job completion, while withholding secret access on fork pull requests.

Runtime secrets injection (Secrets Manager and KMS integration):

  Runner startup (before any user script):
  1. Runner presents OIDC JWT: { sub: "repo:org/app:ref:refs/heads/main", aud: "cicd" }
  2. Control plane validates JWT signature, issuer, audience, subject, and expiry, then maps it to secret scope: repo/org/app/env:production
  3. Batch fetch from Secrets Manager: GET /v1/secrets/runtime?scope=...&ttl=1800
     - Database password (dynamic lease 30 min)
     - NPM_TOKEN (rotated, masked)
     - AWS_ROLE_ARN: exchange OIDC for STS credentials with no stored AWS keys
     - Refresh short lived cloud credentials before expiry while the job remains authorized
  4. Inject secrets into the runner process namespace without writing them to persistent disk
  5. Log scrubber registers secret values for runtime redaction
  6. On job termination (success/fail/cancel): revoke dynamic leases and perform best effort process cleanup

Security note: environment variables can be inherited by child processes, so masking and sandboxing are defense in depth. Secret scope is based on the trusted repository, ref, environment, and approval policy rather than repository membership alone. Trusted job code must still be treated as able to exfiltrate any secret it is authorized to receive.

Fork PRs from untrusted contributors: secrets scope is empty, caches are isolated from trusted branches, and only read only repository tokens are available

8. Artifact Store and Layer Cache

Artifact and log streaming bypasses the central control plane, using direct presigned uploads to Blob Storage (S3) with incremental chunk flushes to survive runner crashes.

  • Artifact storage: Runners upload build outputs directly via short lived, job scoped presigned PUT requests to s3://artifacts/{tenant}/{pipeline_id}/{job_id}/, with content length and checksum validation before completion is recorded. Finalized artifacts are immutable and referenced by an artifact manifest in PostgreSQL.
  • Cross-job passing: Dependent jobs download upstream artifacts using the pipeline ID and upstream job identifier.
  • Layer caching: Restores content addressable cache archives at job initialization and persists updated layers on success when cache paths change.
  • Log streaming: Flushes logical log checkpoints every 5 seconds during execution, batches them into larger ordered object-storage segments, and uses job and chunk sequence metadata so missing or duplicate chunks can be detected during assembly. Partial output therefore survives unexpected runner terminations without requiring one object-storage request per 5-second checkpoint.

API Design

Domain Types and Service Contracts

Keep the public API model separate from internal database rows. Lease and attempt identifiers make runner updates conditional, while idempotency keys make retried triggers safe to process more than once.

TYPESCRIPT
type RepoId = string;
type PipelineId = string;
type JobId = string;
type AttemptId = string;
type LeaseToken = string;
type PipelineRef = string;
type PipelineSha = string;
type RunnerLabel = string;
type IdempotencyKey = string;

type JobStatus =
  | "PENDING"
  | "READY"
  | "QUEUED"
  | "RUNNING"
  | "SUCCEEDED"
  | "FAILED"
  | "SKIPPED"
  | "CANCEL_REQUESTED"
  | "CANCELLED";

type RunnerStatusUpdate = "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";

interface TriggerPipelineRequest {
  ref: PipelineRef;
  sha?: PipelineSha;
  variables?: Record<string, string>;
  idempotencyKey: IdempotencyKey;
}

interface IdempotencyRecord {
  key: IdempotencyKey;
  requestHash: string;
  pipelineId: PipelineId | null;
}

interface ClaimJobResponse {
  jobId: JobId;
  pipelineId: PipelineId;
  attemptId: AttemptId;
  leaseToken: LeaseToken;
  labels: RunnerLabel[];
  timeoutSeconds: number;
}

interface JobStatusUpdate {
  status: RunnerStatusUpdate;
  attemptId: AttemptId;
  durationMs?: number;
  conclusion?: "PASSED" | "FAILED" | "CANCELLED";
}

type JobStatusResult = "ACCEPTED";

interface CIPlatform {
  triggerPipeline(request: TriggerPipelineRequest): Promise<{ pipelineId: PipelineId; status: "PENDING" }>;
  claimJob(labels: RunnerLabel[]): Promise<ClaimJobResponse | null>;
  reportJobStatus(jobId: JobId, update: JobStatusUpdate): Promise<JobStatusResult>;
}

Trigger Pipeline (Manual)

Manual pipeline triggers and internal webhook ingestion share the same compiler pipeline. Manual requests persist a tenant scoped idempotency record with a request hash before creating a pipeline. Runners claim jobs using atomic lease tokens, extend leases through periodic heartbeat patches, and release them through conditional state transitions. Idempotency keys deduplicate retried events, while attempt IDs prevent stale runners from updating a newer retry. Reusing an idempotency key with a different request hash returns 409 Conflict.

HTTP
POST /v1/repos/{repo_id}/pipelines
Authorization: Bearer <token>
Idempotency-Key: idem-uuid-12345
Content-Type: application/json

{
  "ref": "refs/heads/main",
  "sha": "abc123...",
  "variables": { "DEPLOY_ENV": "staging" }
}

Response: 202 Accepted
{
  "pipeline_id": "pipe_uuid",
  "status": "PENDING",
  "web_url": "https://ci.example.com/org/app/pipelines/pipe_uuid"
}

Runner Claim Job

HTTP
POST /v1/runners/claim
Authorization: Bearer <runner_registration_token>
X-Runner-Labels: linux,large

Response: 200 OK (job available)
{
  "job_id": "job_uuid",
  "pipeline_id": "pipe_uuid",
  "attempt_id": "attempt_uuid",
  "lease_token": "lease_secret",
  "steps": [
    { "name": "checkout", "script": "git clone ..." },
    { "name": "test", "script": "npm test" }
  ],
  "secrets_endpoint": "/v1/jobs/job_uuid/secrets",
  "artifact_upload_url": "https://s3.../job-scoped-presigned",
  "timeout_seconds": 1800
}

Response: 204 No Content (no jobs: poll again in 3s)

Report Job Status

HTTP
PATCH /v1/jobs/{job_id}
Authorization: Bearer <lease_token>

{
  "status": "SUCCEEDED",
  "conclusion": "PASSED",
  "duration_ms": 342000,
  "attempt_id": "attempt_uuid",
  "outputs": { "image_tag": "app:v1.2.3" }
}

# Heartbeat (extends lease only for the current attempt):
PATCH /v1/jobs/{job_id}/heartbeat
Authorization: Bearer <lease_token>
X-Job-Attempt-Id: attempt_uuid

# Request a direct log upload URL:
POST /v1/jobs/{job_id}/logs/presign
Authorization: Bearer <lease_token>

# Runner uploads the log chunk directly to object storage using the returned URL:
PUT https://s3.../presigned-log-chunk
Content-Type: application/octet-stream
X-Log-Offset: 65536

Cancel Job

HTTP
POST /v1/jobs/{job_id}/cancel
Authorization: Bearer <token>

Response: 202 Accepted
{
  "job_id": "job_uuid",
  "status": "CANCEL_REQUESTED"
}

The scheduler performs a conditional state transition to CANCEL_REQUESTED. The runner stops on the next control heartbeat, and the terminal cancellation update is accepted only for the current attempt. If the lease expires first, the reaper checks CANCEL_REQUESTED before creating a replacement attempt and marks the job CANCELLED.

Webhook Ingestion (Internal VCS Callback)

HTTP
POST /v1/internal/git-events
X-Git-Event-Delivery: delivery_uuid
X-Git-Event-Type: push
X-Git-Event-Timestamp: 2026-09-12T19:40:00Z
X-Git-Signature: sha256=<hmac>

{
  "repo_id": "repo_uuid",
  "ref": "refs/heads/feature/auth",
  "sha": "def456...",
  "pusher": "developer@example.com",
  "commits": [...]
}

Response: 202 Accepted
{ "pipeline_id": "pipe_uuid", "jobs_created": 8 }

Common Error Responses

400 Bad Request: invalid input, missing required fields, or malformed JSON payload
401 Unauthorized: missing or invalid authentication token or API key
403 Forbidden: authenticated caller lacks required permissions for this resource
404 Not Found: requested resource ID does not exist
409 Conflict: duplicate write or version conflict, retry with a unique idempotency key
422 Unprocessable Entity: syntactically valid request failed semantic business validation
429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header
500 Internal Error: unexpected server failure, retry safely with an idempotency key
503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff
202 Accepted: asynchronous job queued successfully, poll GET /jobs/{id} for completion status
408 Request Timeout: background job is still executing, continue polling status endpoint
403 Forbidden: pipeline blocked: fork from untrusted contributor or branch protection violation
429 Too Many Requests: tenant concurrency or queue quota exceeded
409 Conflict: job attempt is stale, already claimed, or no longer owns the execution lease, or the same idempotency key was reused with a different request
410 Gone: the pipeline run has been cancelled or the requested resource is no longer available
422 Unprocessable Entity: invalid pipeline YAML: cyclic needs dependency detected

Data Model

PostgreSQL: Pipelines and Jobs

SQL
CREATE TABLE pipeline_runs (
    pipeline_id     UUID PRIMARY KEY,
    tenant_id       UUID NOT NULL,
    repo_id         UUID NOT NULL,
    ref             VARCHAR(512) NOT NULL,
    sha             VARCHAR(64) NOT NULL,  -- supports SHA-1 and SHA-256 object IDs
    status          VARCHAR(16) NOT NULL,  -- PENDING, RUNNING, SUCCEEDED, FAILED, CANCEL_REQUESTED, CANCELLED
    trigger_type    VARCHAR(32) NOT NULL,  -- push, pull_request, schedule, manual
    created_at      TIMESTAMPTZ NOT NULL,
    started_at      TIMESTAMPTZ,
    finished_at     TIMESTAMPTZ
);

CREATE INDEX idx_repo_status ON pipeline_runs (repo_id, created_at DESC);

CREATE TABLE jobs (
    job_id              UUID PRIMARY KEY,
    pipeline_id         UUID NOT NULL REFERENCES pipeline_runs(pipeline_id),
    tenant_id           UUID NOT NULL,
    name                VARCHAR(256) NOT NULL,
    matrix_instance_key VARCHAR(512) NOT NULL DEFAULT '',
    stage                VARCHAR(128),
    status               VARCHAR(16) NOT NULL,
    runner_labels       JSONB NOT NULL DEFAULT '[]'::jsonb,
    idempotency_key     VARCHAR(256),       -- deploy deduplication key
    attempt_id          UUID,
    lease_runner_id     UUID,
    lease_token_hash    VARCHAR(128),
    lease_expires       TIMESTAMPTZ,
    retry_count         INT NOT NULL DEFAULT 0,
    started_at          TIMESTAMPTZ,
    finished_at         TIMESTAMPTZ,
    UNIQUE (pipeline_id, name, matrix_instance_key)
);

CREATE TABLE job_attempts (
    attempt_id          UUID PRIMARY KEY,
    job_id              UUID NOT NULL REFERENCES jobs(job_id),
    runner_id           UUID,
    lease_token_hash    VARCHAR(128),
    status               VARCHAR(16) NOT NULL,
    started_at          TIMESTAMPTZ,
    finished_at         TIMESTAMPTZ,
    retry_number       INT NOT NULL,
    failure_reason     TEXT
);

CREATE UNIQUE INDEX uq_job_attempt_retry ON job_attempts (job_id, retry_number);
CREATE INDEX idx_job_attempts_job ON job_attempts (job_id, retry_number DESC);

CREATE TABLE webhook_deliveries (
    delivery_id      VARCHAR(256) NOT NULL,
    source           VARCHAR(64) NOT NULL,
    repo_id          UUID NOT NULL,
    received_at      TIMESTAMPTZ NOT NULL,
    pipeline_id      UUID,
    PRIMARY KEY (source, delivery_id)
);

CREATE TABLE idempotency_records (
    tenant_id       UUID NOT NULL,
    idempotency_key VARCHAR(256) NOT NULL,
    request_hash    VARCHAR(128) NOT NULL,
    pipeline_id     UUID,
    created_at      TIMESTAMPTZ NOT NULL,
    expires_at      TIMESTAMPTZ,
    PRIMARY KEY (tenant_id, idempotency_key)
);

CREATE INDEX idx_idempotency_expiry ON idempotency_records (expires_at);

CREATE TABLE artifacts (
    artifact_id     UUID PRIMARY KEY,
    job_id          UUID NOT NULL REFERENCES jobs(job_id),
    object_key      VARCHAR(1024) NOT NULL,
    sha256          CHAR(64) NOT NULL,
    size_bytes      BIGINT NOT NULL,
    content_type    VARCHAR(256),
    status          VARCHAR(16) NOT NULL, -- UPLOADING, FINALIZED, DELETED
    created_at      TIMESTAMPTZ NOT NULL,
    finalized_at    TIMESTAMPTZ,
    UNIQUE (job_id, object_key)
);

CREATE INDEX idx_artifacts_job ON artifacts (job_id);

CREATE TABLE outbox_events (
    event_id         UUID PRIMARY KEY,
    aggregate_type   VARCHAR(64) NOT NULL,
    aggregate_id     UUID NOT NULL,
    event_type       VARCHAR(64) NOT NULL,
    payload          JSONB NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL,
    published_at     TIMESTAMPTZ
);

CREATE TABLE job_dependencies (
    child_job_id    UUID NOT NULL REFERENCES jobs(job_id),
    parent_job_id   UUID NOT NULL REFERENCES jobs(job_id),
    PRIMARY KEY (child_job_id, parent_job_id)
);

CREATE TABLE tenant_quotas (
    tenant_id               UUID PRIMARY KEY,
    max_concurrent_jobs     INT NOT NULL DEFAULT 500,
    max_queue_depth         INT NOT NULL DEFAULT 2000,
    scheduling_weight       INT NOT NULL DEFAULT 1,
    artifact_quota_gb       INT NOT NULL DEFAULT 100
);

Redis: Leases and Fair Share Queues

REDIS
job:lease:{job_id}           -> { attempt_id, runner_id, token }, TTL = job_timeout + buffer
job:dispatch:{job_id}        -> scheduler_id, TTL 60s (duplicate publication guard only, as outbox remains authoritative)
deps:{child_job_id}          -> int counter (derived unsatisfied parent count)
queue:{priority}:{tenant_id} -> ZSET of job_ids (within-lane fair share ordering)
tenant:running:{tenant_id}   -> int concurrent job count
concurrency:{group_key}      -> distributed lock for mutex groups
idempotency:{tenant_id}:{key} -> optional cached result / request hash for manual triggers
repair: rebuild derived counters from PostgreSQL after Redis loss
Durable dispatch source of truth: PostgreSQL outbox_events

Event Bus Design (Kafka)

Topic: pipeline-events
  Partitions: 256 (partition by repo_id for ordering per repository)
  Retention: 30 days
  Events: pipeline.created, pipeline.started, job.queued, job.started, job.completed,
          job.failed, job.cancelled, artifact.uploaded
  Payload: { event_id, tenant_id, repo_id, pipeline_id, job_id, attempt_id?, status, trace_id }
  Producer: transactional outbox publisher from PostgreSQL state changes
  Idempotency: event_id is unique for publication and consumers tolerate redelivery
  Consumer groups: lifecycle, webhook fanout, metrics indexer, and audit consumers use independent groups

Topic: jobs.dispatch (priority lanes)
  jobs.dispatch.p0: merge queue, protected branch (32 partitions, key=repo_id)
  jobs.dispatch.p1: default push/PR (128 partitions, key=tenant_id)
  jobs.dispatch.p2: scheduled/nightly (64 partitions, key=tenant_id)

Dispatch envelope: { event_id, job_id, attempt_id?, tenant_id, repo_id, priority, trace_id }
Consumer: Runner Fleet Manager: partition consumers share work, while scheduler quotas provide tenant isolation
Consumer: Webhook fanout: notify code hosting integrations, Slack, and deployment APIs
Consumer: Metrics indexer: track queue wait time and success rate per repo in metrics storage

DLQ: jobs.dispatch.dlq: alert on any message and enable manual replay via console

Fault Tolerance

Fault Tolerance Scenarios

ConcernSolution
Runner crash during jobLease based execution follows distributed job scheduler patterns: heartbeats every 60s extend the Redis lease. On expiration, the scheduler creates a new job attempt only after verifying that the prior lease has expired. Each attempt carries a unique lease token, and status transitions are accepted only for the current attempt. Idempotent deployment steps use deduplication keys. Systems cap retries at 3 attempts with exponential backoff, and logs flush incrementally to S3 so partial execution output is preserved.
Noisy neighbor when one tenant floods the queueFair share scheduling uses weighted admission credits per tenant ID. Concurrency caps limit active jobs per organization, with a default of 500. Priority queues separate P0 merge work, P1 default pushes, and P2 scheduled batch runs. Aging promotes starved P2 jobs after 2 hours, while Kafka partitions key by tenant where ordering affinity is useful. Partitioning alone is not a hard isolation boundary, so concurrency quotas and scheduler policy enforce tenant isolation.
DAG deadlock from circular dependenciesThe pipeline compiler validates directed acyclic graphs during parsing using topological sorting. At runtime, if a parent fails without an allow_failure flag, all dependent child jobs mark as SKIPPED. Stale RUNNING parents with expired leases transition to FAILED to cascade down the graph, and an admin API provides manual job skip capabilities.
Secret leaked in build logsSecrets are fetched from the secrets manager and injected by the runner agent as masked environment variables. Log scrubbers redact known secret values before upload to S3, but they cannot guarantee that arbitrary job code will not transform or exfiltrate a secret that it can access. Short lived OIDC tokens (15 min) replace long lived cloud keys, and dynamic secret leases are revoked when teardown runs, with TTL based expiry protecting against runner crashes.
Artifact store exhaustionEnforce per repository quotas (100 GB default) with strict time to live policies: 7 days for build artifacts and 30 days for logs. Use zstd compression on upload and transition files to colder object storage tiers after 7 days. Large artifacts (>1 GB) use presigned multipart URLs so runners upload directly to S3 without burdening control plane proxies. Incomplete multipart uploads are aborted by lifecycle cleanup after the configured grace period.
Scheduler control plane outageStateless API tier is backed by durable PostgreSQL state and a transactional outbox for state change events. In flight jobs continue running until their leases expire, while Kafka preserves undispatched jobs with a replication factor of 3. Standby schedulers elect an active leader via consensus. Recovery targets an RTO under 60 seconds, with at least once dispatch and duplicate side effects prevented by conditional lease claims and idempotency keys.
Malicious pipeline YAML (crypto miner)Runners execute in unprivileged sandboxes with strict CPU and memory limits, seccomp or AppArmor style restrictions, and outbound network allowlists. Runner images are immutable, signed, and verified before launch, with image provenance recorded for incident response. Jobs cannot access a privileged container runtime socket. Untrusted fork pull requests receive zero secret injection and isolated dependency caches. Production deployment stages mandate manual approval gates, and anomaly detectors monitor runner resource utilization across tenants.

Additional Considerations

Relationship to Code Hosting Platforms

Code Hosting Platform (GitHub) manages Git object storage, push and pull protocols, pull request reviews, and branch protections, explicitly treating CI/CD execution as external. This platform provides that dedicated CI/CD depth by consuming webhook events from the code hosting Kafka bus, fetching commit trees through internal APIs, and reporting commit status results back to update pull request checks.

Relationship to Distributed Job Schedulers

Distributed Job Scheduler establishes the core primitives: priority queuing, DAG dependency counters, lease based execution, and deduplication. This CI/CD architecture applies those primitives to developer workflows, adding YAML compilation, matrix expansion, ephemeral runner lifecycle management, and artifact caching. For long running, multi hour deployment workflows with compensation logic, compare this with Workflow Orchestration (Temporal).

Relationship to Secrets Management and KMS

Runners never embed credentials in container images or repository configuration. At job startup, the control plane retrieves scoped secrets from Secrets Manager & KMS alongside short lived cloud credentials exchanged through OIDC. All dynamic leases terminate automatically during runner teardown, and fork pull requests from external contributors receive an empty secret scope to prevent exfiltration.

Merge Queue (Staff Extension)

High traffic repositories serialize merges targeting main branches: pull requests enter a merge queue where pipelines run against speculative merge commits, automatically merging upon success. This prevents scenarios where individually green pull requests break the main branch due to concurrent merges. Merge queues operate as high priority concurrency groups. Each queued merge is tested against a speculative merge commit and must be revalidated if the target branch advances before the final merge.

Interview Walkthrough

  • 25-minute interview structure
    • Clarify system boundary with code hosting platforms (5 min)
    • Architecture flow: webhooks, compiler, DAG scheduler, queues, and runners (6 min)
    • Fleet capacity estimation using Little's Law: 500K concurrent across 30 minutes (5 min)
    • Lease management and idempotent deployment steps (5 min)
    • Runtime secrets injection via OIDC and fork security protections (4 min)
  • Explain the boundary separating Git repository hosting from CI/CD execution engines.
  • Illustrate the core pipeline progression from webhook trigger to ephemeral runner execution.
  • Size the runner fleet using Little's Law, emphasizing compute fleet scale over database volume.
  • Detail lease renewal mechanics, stale attempt protection, and stable idempotency keys for deployment side effects.
  • Describe runtime secret injection through OIDC federation, highlighting untrusted fork safeguards.
  • Address multi tenant fairness algorithms to prevent noisy neighbors from starving queue capacity.
  • Highlight the architectural necessity of isolated ephemeral sandboxes rather than persistent shared hosts.

Engineering Trade-offs

Ephemeral vs Reusable Runners

Ephemeral runners destroy the virtual machine or container immediately after job completion, providing strong isolation, reducing cross job credential leakage, and preventing dirty workspace state at the expense of higher cold start provisioning latency. Reusable persistent runners eliminate provisioning delays but introduce substantial risks of leftover filesystem state and credential residue, making them acceptable only for single tenant self hosted deployments with rigorous cleanup automation. Managed CI/CD platforms commonly use ephemeral sandboxes for hosted tiers.

Warm Pool Sizing

Maintaining a larger warm pool of pre started runner instances minimizes queue wait times and enhances developer feedback, but incurs continuous idle compute costs. Sizing warm pools to approximately 10% of peak concurrency provides a balanced baseline, with dynamic tuning based on regional provisioning latency. Platforms optimize cost by using spot or preemptible instances for low priority batch workloads while reserving reliable on-demand instances for critical merge queues.

Centralized vs Distributed Schedulers

A centralized leader elected scheduler simplifies DAG state consistency and atomic dependency decrements. Sharding scheduler workers by repository ID scales throughput past 25M jobs per day, but complicates cross repository dependency tracking in large monorepos. Best practice begins with centralized leader elected scheduling and transitions to repository partitioned scheduling when database write throughput on job state transitions reaches limits, following Replication, Failover, and Leader Election.

Artifact Retention vs Storage Cost

Retaining build outputs for 90 days assists historical debugging and compliance audits, but drives petabyte-scale storage costs. Enforcing a 7-day retention period for build artifacts and 30 days for execution logs balances operational needs with storage budgets. Issuing presigned URLs for direct uploads to Blob Storage (S3) avoids saturating control plane network bandwidth.

Matrix Fan-Out Limits

Unconstrained matrix declarations can generate thousands of job combinations, overwhelming tenant concurrency quotas and queue capacity. Production systems enforce compile time thresholds on the Cartesian product (such as a maximum of 256 generated jobs per pipeline run) while offering dynamic matrix generation from script outputs protected by approval gates.

Self Hosted vs Hosted Runners

Hosted runners grant the platform complete control over sandboxing, security enforcement, and elasticity, but require the provider to absorb compute expenses. For untrusted multi tenant workloads, hardened VM or microVM isolation is preferred when stronger isolation than a shared kernel can provide is required. Self-hosted runners offload infrastructure costs to customers while reducing provider visibility into environment security. Hybrid architectures represent the standard for enterprise workloads, where public tests execute on hosted runners and sensitive production deployments run on self hosted agents within private virtual networks.

💬Review

Help Us Improve

How helpful was this walkthrough?

Click a star to rate. We actively use this feedback to refine and update our system design content.

Placeholder
Optional but highly appreciated!

Discussion

Share your thoughts, ask questions, or help others.

Loading comments...