GitLab, 2016-2026  / field guide
Practitioner field guide · 6 September 2026

The monolith that could not be split: ten years of GitLab architecture, 2016–2026

GitLab is the rare large engineering organisation that argues about its architecture in public: design documents with a status field, twenty-eight numbered decision records for one programme, rejected proposals kept in a rejected/ directory, and incident reviews with the blast radius filled in. This guide reconstructs the decade from those artefacts and extracts the rule that decided almost every fork in the road, which turns out not to be traffic.

32 primary artefacts 1 production system, ten years 7 incident reviews Evidence through September 2026 Read: 22 min
01

The territory

One codebase has to serve the largest instance of itself and tens of thousands of installations that customers run on their own hardware. Every scaling move has to survive both. That constraint, not request volume, explains most of what follows.

2,975 GB
Size of one table, ci_builds, at 22.7% of the entire production database
2.2M
Lines of Ruby in the monolith the decomposition proposal is still trying to divide
4–6 GB
Anonymous memory one git-pack-objects clone of the main repository holds, capping clones per node
4 + ~20
Postgres clusters and Redis estates the shared tier has been split into

State the problem without naming the company: you run a stateful application whose data model is one connected graph, you host the busiest instance of it yourself, and you ship the identical software to customers who run it on a single virtual machine. You cannot adopt an architecture your customers cannot operate, and you cannot refuse to scale the instance you host. Most standard answers, managed sharded databases, a Kafka bus, edge compute, a service mesh, fail the first test or fork the product into two products.

I am going to call that constraint the parity tax, and I am naming it because the sources do not. They call it "self-managed support", "feature parity across deployment models", or a non-goal. It shows up in the routing decision, where GitLab's own architecture decision record notes that Cloudflare Workers meet every requirement "apart from the self-managed, which is a low priority requirement". It shows up in change data capture, where Debezium was rejected because it "requires Kafka (this can be a problem for self-managed instances)". It shows up in analytics, where ClickHouse shipped for the hosted product in FY24 Q3 while self-managed integration was deferred "due to the uncertain costs and management requirements". Three teams, three technologies, one tax.

The finding that surprised me

The cellular architecture, GitLab's flagship answer to horizontal scale, was put on hold in its entirety. The design document lists Cells 1.0, 1.5 and 2.0 each as "(On hold)" and says "Protocells replaces Cells 1.0, Cells 1.5, and Cells 2.0 with a new focus of permanently reducing load on the database". That is four years of work, twenty-eight numbered decision records and a live routing service re-aimed at a narrower target, with regional compliance, multi-cloud support, load re-balancing and global administration moved to the non-goals list. In the same window, the Gitaly team wrote of Praefect, the clustering layer they shipped for replication and read distribution, that it "has basically failed to yield a horizontally scalable cluster" and is "treated as a failed design".

Read those two together and the decade has a shape. Every attempt to scale a shared stateful tier while leaving the ownership boundary alone bought between one and three years and left a new consistency obligation behind. The only changes that removed load permanently changed who owns the data: move blobs out of Postgres, move analytics into a different engine, move a customer's whole organisation onto its own instance. Those are product decisions wearing infrastructure clothes, which is why they take four years.

Figure 1 · Three eras, and the forcing function for each

2016-2019
Get Git off the
network filesystem

Forcing function:
P99 of 30s to open a repository

2020-2023
Split the shared tier
by function

Forcing function:
one table at 22.7% of the database

2024-2026
Split by tenant,
or reduce the load

Forcing function:
the shared database itself,
named as the bottleneck

2016-2019
Get Git off the
network filesystem

Forcing function:
P99 of 30s to open a repository

2020-2023
Split the shared tier
by function

Forcing function:
one table at 22.7% of the database

2024-2026
Split by tenant,
or reduce the load

Forcing function:
the shared database itself,
named as the bottleneck

Each era ends when the previous answer stops working rather than when a plan completes. Dates from the design documents cited in the evidence wall; the 2026 column reflects documents with a creation-date in 2026.
Diagram source

What this guide covers: the architecture of one company's hosted platform and the decisions behind it, traced through primary artefacts published between 2016 and September 2026. What it deliberately does not cover: comparison with competitors, cost, and anything the public record cannot support. This dig ran under a network policy that reached gitlab.com and github.com and nothing else, so there are no conference talks, no papers, and no independent measurement here. Every figure below is GitLab measuring its own systems for its own engineers, which is credible and is not the same as corroborated.

02

How it is actually built

The 2026 shape, reconstructed from the runbooks repository, the cells decision records and the service documentation. Note where the diagram is wide: that width is the decade's work.

The application is still one Rails codebase. What changed underneath it is the shared tier. The runbooks repository, generated from the SRE team's service catalogue, holds directories for patroni, patroni-ci, patroni-registry and patroni-sec, each with a matching pgbouncer, and roughly twenty Redis estates: redis-sidekiq, redis-sessions, redis-cluster-cache, redis-cluster-shared-state, redis-ratelimiting, redis-tracechunks and more, plus memorystore entries for the managed instances they are moving to. One database and one Redis became four and twenty by pulling workloads apart one at a time, each split justified by its own incident history.

Figure 2 · The shared tier after a decade of splitting

logical replication
via Siphon CDC

Four Patroni clusters

main

ci

registry

sec

Rails application

Puma + Workhorse

Sidekiq, sharded by queue

Cloudflare edge
HTTP Router Worker

Redis, ~20 estates
queues, cache, sessions,
rate limiting, trace chunks

Gitaly nodes
local disk per node

Object storage
artifacts, LFS, uploads

ClickHouse

Topology Service
on Cloud Spanner

logical replication
via Siphon CDC

Four Patroni clusters

main

ci

registry

sec

Rails application

Puma + Workhorse

Sidekiq, sharded by queue

Cloudflare edge
HTTP Router Worker

Redis, ~20 estates
queues, cache, sessions,
rate limiting, trace chunks

Gitaly nodes
local disk per node

Object storage
artifacts, LFS, uploads

ClickHouse

Topology Service
on Cloud Spanner

Every box below the application row was carved out of something that used to be single. Reconstructed from the runbooks service documentation, the cells design document and the Siphon design document.
Diagram source

Git storage: three generations

Repositories moved from a shared network filesystem to Gitaly, an RPC service that keeps Git on local disk, then to Praefect for replication, and now toward object storage with an MVCC layer. Each generation was a response to the previous one's ceiling, and the current proposal states the goal plainly: "Gitaly nodes are stateless."

Sources: Gitaly design, Scaling Git

Queues: sharded in the application

Sidekiq runs against many Redis instances, routed at the application layer, because "Redis Cluster is not suitable for Sidekiq since there are only a small subset of hot keys". The SRE recommendation is one queue per shard, which makes a shard a blast-radius boundary as much as a capacity one.

Source: Sidekiq sharding runbook

Analytics: a second engine and a CDC pipe

Analytical queries left the OLTP database for ClickHouse, fed by Siphon, a purpose-built logical-replication consumer. The motivation section is unusually direct: analytical workloads in the OLTP database "led to numerous production incidents over the years".

Sources: ClickHouse usage, Siphon

The divergence point worth marking is the routing layer at the top of the diagram. It exists because of the cells programme, it runs on Cloudflare Workers rather than in GitLab's own compute, and the HTTP router project has been under active development since February 2024. For a self-managed customer that row does not exist. For GitLab.com it is now the first thing every request touches, which is worth holding in mind when the failure catalogue shows a Cloudflare outage taking the whole site down for twenty-five minutes in December 2025.

Two components deserve attention because they show the cost of the splits rather than the benefit. First, ci_namespace_mirrors and ci_project_mirrors: when the CI tables moved to their own database, the namespaces and projects rows they referenced could no longer be joined, so those rows are copied into the CI database and reconciled by consistency-check workers, with a runbook for the alerts that fire when the copies drift. The runbook notes that the mirroring happens "even ... on single database mode", which is the parity tax again: the hosted platform's sharding scheme became part of the software everyone runs. Second, the Topology Service, a new cluster-wide component on Cloud Spanner that exists only to answer "which cell owns this organisation". Partitioning a system always adds a component whose job is to know where things are, and that component is necessarily global.

03

The decisions that matter

Five forks, each with the rejected option and the stated reason, and the condition under which the rejected option becomes the right one for you.

Decision: how do you scale Git storage for one very large repository?

Chosen
  • 2016: an RPC service, Gitaly, that keeps Git next to local disk
  • 2026 proposal: object storage as the source of truth with a Git MVCC backend, so "losing a node (and its disk) causes degraded performance (cold cache), but it does not cause data loss or unavailability"
Rejected
  • Network filesystems: "the increased access latency has proven to be a challenge" plus non-POSIX edge cases
  • Praefect replication with transactional voting: "treated as a failed design"
  • Git objects in a database: reading the object graph individually is "unworkable" given round-trip times
Flips when
  • Your repositories are small enough that a whole clone is not a memory event. The 4 to 6 GB that one git-pack-objects holds is what makes a node's ceiling a memory ceiling; below that, replication is adequate and much simpler.

Decision: the biggest table is 22.7% of the database. Split the database or shrink the table?

Chosen
  • Both, in order: shrink first with a hard target of "less than 100 GB for a physical table ... including its indexes", then decompose into separate main and ci databases
  • Partition by time decay, using the product rule that queued builds older than 24 hours are dead
Rejected
  • Redis as the build queue, in favour of a dedicated Postgres queuing table
  • Continued vertical scaling: the design document states they were "at the limits of vertical scaling of the CI primary database nodes"
Flips when
  • Your hot table has no natural time dimension. Time decay works here because the product already discards old queue entries; without such a rule, partitioning moves the problem rather than shrinking it, and decomposition becomes the first move rather than the second.

Decision: where does the tenant boundary live?

Chosen
  • A product-level concept, the Organization, as the logical boundary, and a Cell as the physical one, with "a one-to-one mapping between instance and Organization on Self-Managed and Dedicated"
  • Cluster-wide unique identifiers from the start "so that we can perform logical re-balancing at a later time"
Rejected
  • Multiple independent instances: "No shared resources or features ... Does not provide the unified GitLab experience"
  • The full cellular scope: regional compliance, multi-cloud and load re-balancing are now explicit non-goals
Flips when
  • Your users belong to exactly one tenant. GitLab's hard case is the user who contributes to a private enterprise organisation and to public open-source projects, so the boundary cannot be drawn around users. Where it can, independent instances are genuinely simpler and the routing layer disappears.

Decision: build the routing layer, or buy the edge?

Chosen
  • Cloudflare Workers in TypeScript, with the expectation that the router stays "very small and simple (up to 1000 lines of code)"
  • Static rules and HTTP caching, later a router library, migrated rule by rule with fall-through to the original engine
Rejected
  • Istio, after a proof of concept
  • Request buffering and route learning, both preserved as documents under cells/rejected/
  • A hand-built optimised router: "likely wouldn't match the performance or maturity of established router libraries"
Flips when
  • You need the same routing on premises. GitLab accepted that this component does not exist for self-managed customers and called that requirement low priority. If your deployment model does not allow that trade, the edge answer is closed to you and you are back to a mesh or a proxy tier you operate.

Decision: adopt a change-data-capture tool, or write one?

Chosen
  • Siphon, a first-party producer reading the Postgres logical replication stream into a queueing system, deployable "through Kubernetes environment on GitLab.com, Dedicated and Self-Managed"
Rejected
  • Debezium: "requires Kafka (this can be a problem for self-managed instances)"
  • PeerDB: "recently acquired by ClickHouse and they give less or no focus on other data warehouses"
  • Airbyte: "no support for schema changes"
Flips when
  • You ship to one environment you control. Then Debezium's Kafka dependency is a cost, not a disqualifier, and writing your own CDC producer is hard to justify. The deciding input here was the deployment matrix, not the technology.

Figure 3 · Which split to reach for when the shared tier saturates

yes, one table
or one queue

no, broad load

yes

no

yes, by function

no, load is per-tenant

yes

no

Is one object
dominating the tier?

Does the data have
a natural decay rule?

Can you name
a workload boundary?

Partition by time
Cost: retention becomes a product promise

Normalise, externalise blobs,
drop indexes
Cost: buys 12-24 months

Split the store by domain
Cost: lost joins become
mirrored tables plus a reconciler

Do users belong to
exactly one tenant?

Independent instances
Cost: no shared features

Global identifiers now,
routing layer, tenant migration tooling
Cost: multi-year, product change

yes, one table
or one queue

no, broad load

yes

no

yes, by function

no, load is per-tenant

yes

no

Is one object
dominating the tier?

Does the data have
a natural decay rule?

Can you name
a workload boundary?

Partition by time
Cost: retention becomes a product promise

Normalise, externalise blobs,
drop indexes
Cost: buys 12-24 months

Split the store by domain
Cost: lost joins become
mirrored tables plus a reconciler

Do users belong to
exactly one tenant?

Independent instances
Cost: no shared features

Global identifiers now,
routing layer, tenant migration tooling
Cost: multi-year, product change

The tree encodes GitLab's own sequence: shrink, then split by function, then split by tenant. Terminal nodes are actions, and each one names what it costs you.
Diagram source
DecisionChosenRejectedBecauseEvidence
Repository storage, 2016Gitaly RPC on local diskNetwork filesystemP99 of 30 wall seconds to open a repository against 15 ms of CPUGitaly design doc
Repository availability, 2020Praefect replication and votingDoing nothingGitaly had explicitly deferred replication in 2016Gitaly documentation
Repository scale, 2026Object storage plus MVCC, stateless nodesPraefect, networked filesystems, Git in a databasePraefect "treated as a failed design"; writes must land on all nodes at onceScaling Git
Build queue, 2021Dedicated Postgres queuing tablesRedis queuingChosen after evaluating both; the queue was being built from a 2 billion row tableCI/CD scaling
Job fan-out, ongoingApplication-level Sidekiq shardingRedis Cluster"only a small subset of hot keys"Sharding runbook
Registry metadata, 2020Metadata in Postgres, online garbage collectionMetadata in the blob storeOffline mark and sweep needed read-only mode for "hours/days"Registry metadata DB
Cell disaster recoveryBackup and restore (ADR-024)Geo replication (ADR-006)A reversal recorded in the same decision log rather than a rewritten documentCells decision log
CI autoscaling, 2022Provider plugins and a new autoscalerContinuing the Docker Machine forkUpstream stopped non-bugfix work in 2018 and the repository was archiveddocker/machine issue 4537 (now docker-archive-public/docker.machine)

Figure 4 · Ten years of Git storage, including the two dead ends

P99 30s opens

replication deferred
as out of scope, 2016

voting unreliable,
writes need every node

still true in 2026:
one node per repository

Shared network filesystem

Gitaly RPC
Git on local disk

Praefect cluster
replicate and vote

Declared a failed design

Single-node hotspots

Object storage as source of truth
plus a Git MVCC backend

P99 30s opens

replication deferred
as out of scope, 2016

voting unreliable,
writes need every node

still true in 2026:
one node per repository

Shared network filesystem

Gitaly RPC
Git on local disk

Praefect cluster
replicate and vote

Declared a failed design

Single-node hotspots

Object storage as source of truth
plus a Git MVCC backend

Notice that the 2026 design returns to a network-attached store, the thing the 2016 design was built to escape, but moves the consistency mechanism into the application instead of trusting the filesystem. Sources as in the decision table above.
Diagram source
04

What broke in production

Seven incident reviews, published by GitLab with impact numbers filled in. They fall into three classes, and the classes map onto the architecture above more neatly than anyone would want.

First, saturation of a shared queue or pool, where one workload consumes what many workloads share. Second, the coordination plane, the components that decide who is primary and who may connect, whose failure is total rather than partial. Third, a single stateful node, which after a decade still decides availability for the repositories on it. Provider outages are a fourth category, architectural in one respect only: the routing layer the cells programme adds sits inside one of them.

Postmortem

The mitigation that fed the fire

AssumptionDeferring a misbehaving job class relieves pressure on the Redis shard holding its queue.
What happenedA surge of audit-event streaming jobs saturated the catchall_b Sidekiq Redis shard. Deferral was toggled on, and, in the review's words, "deferring ... jobs caused sidekiq itself to re-enqueue more of them, amplifying the problem". Redis memory grew to 35 GiB before an engineer intervened.
Blast radiusAudit events dropped globally for 3 hours 7 minutes on 19 May 2026, for every customer using audit streaming. A near-repeat of an S1 one week earlier with the same worker and the same shard.
FixSwitch from deferring to dropping the jobs. Corrective actions listed under a feature change lock: circuit breaker, runbook, Redis streaming buffer. The review notes those "had not yet been fully deployed when this recurrence happened".
Design ruleA deferral mechanism that re-enqueues is a positive feedback loop wearing a safety label. Any shed-load path must terminate work, not reschedule it, and you should load test the mitigation, not only the system.
Postmortem

Long transactions in background jobs blocked the deploy pipeline

AssumptionBackground jobs are low urgency, so their database usage is not on the critical path.
What happenedTwo worker classes held "long-lived database transactions", saturating PgBouncer and exhausting the pool. Queueing apdex collapsed on the low-urgency and catchall shards.
Blast radius8 hours 24 minutes from 10 March 2026. CI job creation delayed for all users, and "deployments and feature flags were blocked", which removes the two levers you would normally use to mitigate.
FixManual deletion of Sidekiq pods, a feature flag turned off to shed load, and a feature change lock opened for the wider Sidekiq problem.
Design ruleConnection pool capacity is shared by workloads with different urgencies, so a low-urgency job can consume a high-urgency resource. Isolate pools by urgency class, and check whether your mitigation levers depend on the tier that is saturated.
Postmortem

Deleting a coordination component took the whole site down

AssumptionRunning a routine tool from a local development environment is a local action.
What happenedThe action removed the Consul release from the production Kubernetes cluster. Consul underpins Patroni, so the Postgres high-availability layer lost its coordination store and the application lost its databases.
Blast radius30 November 2022. "~100% of traffic", with the CDN reporting "~13M requests that resulted in 503".
FixRe-apply the release. The review points at two structural items already in the backlog: a better Kubernetes deployment path so operators do not run production changes locally, and read-only-by-default access for SREs through Teleport.
Design ruleYour database's availability is bounded by the availability of the thing that elects its primary. Inventory the coordination layer as a tier one dependency, and make write access to it a deliberate act rather than a side effect of a developer's tooling.
Postmortem

A maintenance playbook left the pooler paused

AssumptionA switchover playbook either completes or fails visibly.
What happenedDuring a Consul certificate authority rotation, a Patroni switchover playbook "timed out during that step while waiting for the yubikey", leaving the PgBouncer nodes of the sec cluster paused. Pause is a legitimate intermediate state, and the review notes the playbook produced no error output, so the stall was not obvious.
Blast radius10 July 2025, 24 minutes. 17,383 customers, 445,621 HTTP Git requests and 59,777 Sidekiq jobs affected.
FixRe-run the playbook, which unpaused the pooler. The review's own "what was difficult" notes the lack of error output made the stall hard to spot.
Design ruleEvery operation with an intermediate state needs an alert on the intermediate state itself, not on its success. A human-in-the-loop step, hardware key or approval, turns a timeout into a stuck system, so bound it and roll it back automatically.
Postmortem

One storage node, one namespace, three and a half hours

AssumptionAfter Gitaly and its clustering work, a single storage node is not a single point of failure.
What happenedA Gitaly storage node saw "a sharp spike in anonymous memory usage". Rebooting it as mitigation made things worse: the node "failed to mount its data disk", so the outage continued until the filesystem was repaired from console access.
Blast radius24 March 2026, 3 hours 28 minutes, "All requests within gitlab-org namespace", including every pipeline and integration touching it.
FixFilesystem repair and reboot, with integrity checks afterwards. The structural answer is the 2026 proposal to make nodes stateless over object storage.
Design ruleMemory pressure and a reboot are a compound failure: the recovery action exposes a second, slower failure mode in the same node. Rehearse the reboot, not just the failover, and know how long a cold node takes to become usable.
Postmortem

Two provider outages, two different shapes

AssumptionProvider dependencies degrade features independently of each other.
What happenedOn 12 June 2025 a global Google Cloud failure hit Pages, the registry, runners, LFS, artifacts and traces simultaneously, with the engineer on call "receiving 17+ pages within minutes". On 5 December 2025 a Cloudflare outage returned 500s for every endpoint.
Blast radius2 hours 56 minutes for the cloud outage, severity 1. Twenty-five minutes for the edge outage, "~155 million requests", all SaaS users.
FixWait for the provider in both cases. The December review records follow-up actions "to reduce Cloudflare dependency".
Design ruleObject storage is a shared dependency of features you think of as unrelated, so a storage outage is a correlated failure across your whole feature list. Draw that dependency once and count how many of your user-visible features it terminates.

Figure 5 · How a load-shedding mechanism became the load

OperatorSidekiq workersRedis shardcatchall_bProducersOperatorSidekiq workersRedis shardcatchall_bProducerseach deferral is a new writeto thesaturated shardsurge of audit streaming jobsdequeuecannot keep up, memory climbsenable deferral for this job classre-enqueue deferred jobmemory grows to 35GiBswitch from deferring to droppingjobs dropped, memory recovers
OperatorSidekiq workersRedis shardcatchall_bProducersOperatorSidekiq workersRedis shardcatchall_bProducerseach deferral is a new writeto thesaturated shardsurge of audit streaming jobsdequeuecannot keep up, memory climbsenable deferral for this job classre-enqueue deferred jobmemory grows to 35GiBswitch from deferring to droppingjobs dropped, memory recovers
The loop that made the May 2026 incident self-sustaining: deferral rescheduled work onto the shard it was meant to relieve. Reconstructed from the incident review, which states that sidekiq "became the dominant source of job enqueues".
Diagram source
05

Numbers you can plan against

All of these are GitLab measuring GitLab, taken from design documents and incident reviews. None is independently verified, and the older ones describe a system that has since changed.

MetricValueAtContextAs ofSource
Largest single table, with indexes2,975 GBGitLab.comci_builds, 30 indexes, 22.7% of the databaseJun 2021design doc
Growth of that table300 GB/monthGitLab.comForecast to approach 5 TB within the yearJun 2021design doc
Target physical table size< 100 GBGitLab.comAct at 10 GB; the number is called pragmatic, not derivedJun 2021design doc
Single B-tree index build1.5–6 hGitLab.comBlocks deployments because migrations run synchronously2021design doc
GIN trigram reindex on notesaborted at 12 hGitLab.comBlocked other vacuum activity2021design doc
CI builds created per day> 5 millionGitLab.comTarget for the programme was 20 million per day2021design doc
Cumulative builds1B → 2BGitLab.com1 February 2021 to February 20222022design doc
Builds per day at peak~4 millionGitLab.comStated in the runner autoscaling documentJan 2022design doc
Serialized data in two columns600 GB + 300 GBGitLab.comci_builds.options and ci_builds.yaml_variablesFeb 2021design doc
Memory per in-flight clone4–6 GBGitLab.comgit-pack-objects for the gitlab-org/gitlab repositoryJun 2026design doc
Repository open latency before GitalyP99 ~30 sGitLab.comAgainst 15 ms of CPU time, which is what implicated the filesystem2016design doc
Redis memory before intervention35 GiBGitLab.comSidekiq shard catchall_b during the deferral loopMay 2026incident review
Failed requests, site-wide outage~13 millionGitLab.com503s reported by the CDN, ~100% of trafficNov 2022incident review
Requests during edge outage~155 millionGitLab.com25 minutes, all public APIsDec 2025incident review
Downtime to resize a tenant's stateful services20–40 minGitLab Dedicated on AWSQuoted as the comparison when sizing a cell2024–2026ADR-009
Read these carefully

Everything above is measured by GitLab on its own systems, and nothing is corroborated by an outside party. Three specific cautions. The 2021 database figures describe the state that motivated partitioning and decomposition, so they are a picture of the problem rather than of today. The build-rate figures disagree with each other, "more than 5 million ... each day" in the 2021 document against "around 4 million builds per day at peak" in the 2022 one, which most likely reflects daily total against peak-day rate rather than a decline; the documents do not reconcile them, so treat both as order-of-magnitude. The one number that has aged well is the 100 GB table target, which reads as an operational constant rather than a scale-dependent one, and which the authors themselves call pragmatic. Unknown: nothing in this corpus states a cost, a request rate, or a total database size, so any cost model you build from this page would be invention.

06

The evidence wall

Fifty claims from thirty-two artefacts, all first-party and all fetched on 6 September 2026. There are no blog posts, papers or talks here, and that is a limitation of this dig rather than of the record: the session's network policy reached only gitlab.com and github.com. The upside is that every card below is the artefact itself rather than a description of it.

Decision recordGitLab2016 onward

Gitaly design document

The founding argument for the RPC layer, with the measurement that triggered it and an explicit scope exclusion for replication and high availability.

Carry forwardDeferring availability at the start of a storage project sets up the next decade's problem; write down what you are deferring.
gitlab.com/gitlab-org/gitaly/doc/DESIGN.md
Decision recordGitLab2026-06

Scaling Git: object storage and a Git MVCC backend

Proposes stateless Gitaly nodes over object storage and, in its alternatives section, declares Praefect a failed design with four named mechanisms.

Carry forwardA design document that judges the team's own previous design is worth more than three that only describe the new one.
handbook: design-documents/scaling-git
Case studyGitLab2021-01

CI/CD Scaling

Build volumes, the 32-bit primary key that would have taken the site down before December 2021, and the rejection of Redis for build queuing.

Carry forwardForecast your sequence exhaustion the way you forecast disk; it is the one capacity limit with a hard stop and no graceful degradation.
handbook: design-documents/ci_scale
Case studyGitLab2021-06

Limit on-disk table size to under 100 GB

The top thirty tables with sizes and index counts, plus the operational consequences: six-hour index builds, vacuum that cannot keep up, an abandoned reindex.

Carry forwardTable size is a deployment-frequency problem before it is a query-latency problem.
handbook: design-documents/database_size_limits
Decision recordGitLab2021-09

CI/CD data time decay

Partitioning driven by a product rule that already existed: queued builds older than twenty-four hours are discarded.

Carry forwardThe best partition key is a promise your product already makes to users.
handbook: design-documents/ci_data_decay
SourceGitLabliving

CI mirrored tables runbook

What decomposition left behind: namespaces and projects copied into the CI database, consistency-check workers, and alerts for drift.

Carry forwardEvery foreign key you break becomes a reconciliation job, an alert and a runbook. Budget for all three.
gitlab-com/runbooks: decomposition/ci-mirrored-tables.md
SourceGitLabliving

Sidekiq sharding runbook

Why Redis Cluster was rejected for job queues, and the recommendation of one queue per shard so that a shard is an isolation boundary.

Carry forwardCluster products assume key distribution; queues are the opposite, a few very hot keys. Check the access pattern before the product.
gitlab-com/runbooks: sidekiq/sharding.md
SourceGitLab2026-09

Runbooks service documentation tree

The service inventory, generated from the SRE service catalogue: four Patroni clusters, matching PgBouncer sets, roughly twenty Redis estates, and managed-Redis entries.

Carry forwardA directory listing of runbooks is the most honest architecture diagram an organisation produces.
gitlab-com/runbooks: docs/
Case studyGitLab2020-09

Container registry metadata database

Metadata in the blob store forced offline garbage collection measured in hours or days, and feature work was frozen until the storage model changed.

Carry forwardIf your garbage collector needs the system read-only, your metadata is in the wrong store, and the tell is a feature freeze.
handbook: design-documents/container_registry_metadata_database
Decision recordGitLab2024-11

Siphon: first-party Postgres change data capture

Debezium, PeerDB and Airbyte each assessed and rejected, with the Kafka dependency named as the disqualifier for self-managed installations.

Carry forwardWrite the deployment matrix into the alternatives table; it decides more build-versus-buy calls than benchmarks do.
handbook: design-documents/siphon
Decision recordGitLab2023-02

ClickHouse usage

The second database engine, adopted for analytics on the hosted product first, with self-managed integration deferred over cost and management uncertainty.

Carry forwardAdopting an engine for one deployment model and not the other splits your feature set; decide deliberately which features become hosted-only.
handbook: design-documents/clickhouse_usage
Decision recordGitLab2022-01

Next Runner Auto-scaling Architecture

Documents four years of depending on an abandoned upstream, the decision to fork it, and the plugin architecture meant to end that dependency.

Carry forwardForking an abandoned dependency is a deferral with interest; set the date at which you stop paying it when you fork, not later.
handbook: design-documents/runner_scaling
SourceDocker2018-07

docker/machine issue 4537 (now docker-archive-public/docker.machine)

The upstream announcement that non-bugfix work had stopped, on the repository GitLab's CI autoscaling depended on. The repository is now archived and read-only.

Carry forwardTrack the maintenance status of load-bearing dependencies as a risk with a date, not as a feeling.
github.com/docker-archive-public/docker.machine/issues/4537
Decision recordGitLab2022-09 onward

Cells design document and decision log

Twenty-eight numbered decision records, three iterations marked on hold, and a stated replacement by a narrower programme.

Carry forwardKeep the decision log even when the programme changes shape; the reversals, such as ADR-006 to ADR-024 on disaster recovery, are the useful part.
handbook: design-documents/cells
Decision recordGitLab2026-02

Protocells

The re-scoped programme. Names GitLab.com the "legacy cell", targets database load relief, and moves regional compliance, multi-cloud, rebalancing and global admin to non-goals.

Carry forwardA partitioning programme survives by shedding scope; decide up front which of its promises is the one you will actually keep.
handbook: design-documents/protocells
Decision recordGitLabliving

Cells ADR-001: routing on Cloudflare Workers

Istio rejected after a proof of concept, two alternative routing designs rejected, and the self-managed gap accepted in writing along with the vendor concentration.

Carry forwardAn ADR that names the requirement it fails, and calls that requirement low priority, is a model worth copying.
handbook: cells/decisions/001_routing_technology
Decision recordGitLab2026-02

Cells ADR-026: a router library for path rules

Two years after the custom rule engine shipped, its linear rule evaluation is being replaced progressively, with unmigrated rules falling through to the old engine.

Carry forwardFall-through is the cheapest migration strategy for a routing layer: both engines run, one shrinks.
handbook: cells/decisions/026_hono_for_http_router
Decision recordGitLabliving

Cells ADR-009: initial cell sizes

Sizing a cell from published reference architectures, with the acknowledgement that resizing stateful services later may need twenty to forty minutes of downtime.

Carry forwardAsk what resizing a cell costs before you choose its initial size; that number, not the first bill, is what you will live with.
handbook: cells/decisions/009_cell_initial_sizing
Decision recordGitLabliving

Rejected: stateless router with buffering requests

A complete alternative routing design, kept in a rejected/ directory and cited by the ADR that rejected it.

Carry forwardKeep rejected designs addressable. The next team to ask "why not buffer requests at the router" gets an answer instead of a rerun.
handbook: cells/rejected/proposal-stateless-router-with-buffering-requests
SourceGitLab2024-02 → 2026-09

cells/http-router project

The routing service as a live repository, created February 2024 and active through September 2026, which is what separates this programme from a design exercise.

Carry forwardCheck the repository's last commit before believing an architecture document's status field.
gitlab.com/gitlab-org/cells/http-router
SourceGitLab2024-09, closed

Gitaly MR 7312: Praefect to Raft migration steps

A migration path for replacing Praefect's coordination with Raft, opened as a draft and closed without merging, before the 2026 proposal took a different route entirely.

Carry forwardClosed merge requests date the moment a direction was abandoned more precisely than any document does.
gitlab.com/gitlab-org/gitaly/-/merge_requests/7312
Decision recordGitLab2023-05, still proposed

Rails monolith decomposition

2.2 million lines of Ruby, a status field that still reads proposed three years on, and a fresh justification built around autonomous coding agents.

Carry forward"Data coupling is what actually blocks extraction." Modularising code without splitting the tables it shares does not make a domain extractable.
handbook: design-documents/modular_monolith
Decision recordGitLab2023-04

Organization

The tenancy boundary as a product concept: GitLab.com splits into a shared public experience and isolated enterprise organisations.

Carry forwardIf your infrastructure partition needs a user-visible concept to hang on, ship the concept first and the partition second.
handbook: design-documents/organization
Decision recordGitLab2021-11

Object storage: direct upload consolidation

Consolidates a per-feature upload story into one path, and is the counterpart to moving artifacts, LFS and uploads out of the database entirely.

Carry forwardExternalising blobs is the highest-yield database shrink available, and it arrives as many small feature migrations rather than one project.
handbook: design-documents/object_storage
DocsGitLabliving

Gitaly administration documentation

The end state of the 2016 decision, written as a support boundary: only local storage is supported for repository data, and network filesystems are not.

Carry forwardSupport documentation is where an architectural decision finally becomes irreversible for your customers.
gitlab-org/gitlab: doc/administration/gitaly
PostmortemGitLab2022-11-30

Site-wide outage from a deleted Consul release

Roughly 100% of traffic, about 13 million 503s, caused by a local tool acting on production and removing the coordination layer under Patroni.

Carry forwardThe coordination plane deserves the same change control as the database it elects.
gl-infra/production 8100
PostmortemGitLab2026-05-20

Redis saturation and the deferral feedback loop

Three hours of dropped audit events, 35 GiB of Redis memory, and a mitigation that re-enqueued the jobs it was meant to shed. A repeat of an S1 a week earlier.

Carry forwardTest the load-shedding path under load. It is code, and it can be the amplifier.
gl-infra/production 22170
PostmortemGitLab2026-03-11

PgBouncer saturation from long-lived transactions

Eight hours of queueing violations, with deployments and feature flags blocked, from two background worker classes holding transactions open.

Carry forwardCheck that your mitigation levers do not depend on the saturated tier before you need them.
gl-infra/production 21520
PostmortemGitLab2025-07-10

PgBouncer left paused by a timed-out playbook

17,383 customers and 445,621 Git requests affected in 24 minutes, because a switchover step waiting on a hardware key timed out silently.

Carry forwardAlert on the intermediate state of maintenance operations, not only on their failure.
gl-infra/production 20154
PostmortemGitLab2026-03-24

One Gitaly node takes out a namespace

Memory spike, then a reboot that failed to remount the data disk: three and a half hours for everything in one large namespace.

Carry forwardRecovery actions have their own failure modes. Rehearse the reboot on a real node with real data.
gl-infra/production 21629
PostmortemGitLab2025-06-12

Google Cloud outage across every storage-backed feature

Nearly three hours, severity 1, with Pages, registry, runners, LFS, artifacts and traces failing together and 17 or more pages in minutes.

Carry forwardCount how many user-visible features one storage dependency terminates; that is your real correlated-failure surface.
gl-infra/production 19996
PostmortemGitLab2025-12-05

Edge provider outage, 25 minutes, all APIs

About 155 million requests affected, with follow-up actions recorded to reduce the dependency. The cells routing layer runs at that same provider.

Carry forwardAdding a routing tier at your CDN concentrates two failure domains into one; price that against the operational saving.
gl-infra/production 20942
07

Build a miniature, then productionise it

Six rungs against a toy application with one hot table, one queue and one shared store. The line between toy and real is rung four.

Measure the shape of your data, not its size

Write the query from the GitLab design document that lists the top thirty tables by total size, index size and index count, and run it weekly. Add a forecast from the last twelve weeks and project when each table crosses 100 GB and when each integer sequence exhausts.

Done when: you can name the date each of your top three tables crosses the threshold.  Teaches: capacity work starts with a forecast, and sequence exhaustion has no graceful degradation.

Partition one table on a rule your product already makes

Find the retention promise you already keep, then partition the hot table on it and route reads through a helper that knows about the partitions. Do not invent a new retention rule for the migration.

Done when: dropping the oldest partition is a metadata operation and no application code changed.  Teaches: partitioning is a product decision expressed in DDL.

Split one domain into a second database and pay the bill

Move one domain's tables to a second database. Where a foreign key crossed the line, create a mirror table, a synchronisation path and a consistency checker that emits a mismatch count. Alert on the count.

Done when: you can break the mirror deliberately and your checker finds it before a user does.  Teaches: what decomposition actually costs, which is a reconciliation loop per broken join.

Isolate one workload's queue and prove the isolation

Route one job class to its own queue on its own Redis, then run a load generator that floods it. Watch the other queues. Then enable a deferral or retry mitigation and flood it again, looking specifically for the mitigation increasing enqueue rate.

Done when: the flood degrades only the isolated class, and you have a graph of the mitigation's own load.  Teaches: shard boundaries are blast-radius boundaries, and shed-load paths need their own load test.

Put a routing layer in front and give tenants an identity

Introduce a tenant identifier in every request path, a lookup service that maps tenant to instance, and a router that reads the map. Run two instances behind one hostname. Make every identifier globally unique now, as GitLab did, so a later move is possible.

Done when: a request for tenant B lands on instance B with no application change.  Teaches: partitioning adds a global component whose availability now bounds everything.

Move a tenant between instances and measure what breaks

Migrate one tenant's data to the second instance, flip its mapping, and then go looking for the dangling references: objects on the source that point at the moved tenant, and pages on the target that reference users left behind. Fix them by rendering gracefully rather than by copying more data.

Done when: both instances render correctly with references missing, and you can state the write-unavailable window in seconds.  Teaches: the hard part of cellular architecture is the graph that crosses the boundary, not the routing.

08

Keep hunting

This page was assembled almost entirely through one public REST API. These are the calls that produced it, and they work for any organisation that keeps its engineering record on GitLab.

Design documents and decision records

  • GET /api/v4/projects/42817607/repository/tree?path=content/handbook/engineering/architecture/design-documents&ref=main&per_page=100
  • GET /api/v4/projects/42817607/repository/files/<url-encoded path>/raw?ref=main
  • GET /api/v4/projects/42817607/repository/tree?path=<...>/design-documents/cells/rejected&ref=main

Incident reviews with impact numbers

  • GET /api/v4/projects/7444821/issues?labels=incident-review&order_by=created_at&sort=desc&per_page=40
  • GET /api/v4/projects/7444821/issues?labels=incident-review&created_before=2023-01-01T00:00:00Z
  • GET /api/v4/projects/7444821/issues/<iid> # the Key Information table carries duration and impact

Abandoned directions

  • GET /api/v4/projects/2009901/merge_requests?state=closed&search=praefect
  • jq '.[] | select(.state=="closed" and .merged_at==null) | .title'
  • grep -A2 '^status: proposed' <design-doc>/_index.md # then read creation-date

The real architecture, not the drawn one

  • GET /api/v4/projects/1148549/repository/tree?path=docs&ref=master&per_page=100
  • jq -r '.[].name' tree.json | grep -cE '^(patroni|redis)' # count the estates
  • GET /api/v4/projects/<group%2Fproject> # created_at and last_activity_at

The transferable trick is not the API. It is that four artefact types answer four different questions, and only one of them is the one everybody reads. A design document tells you what a team intended. A rejected/ directory tells you what they considered and refused. A closed merge request tells you when they gave up on something. A runbook directory listing tells you what they actually run. Where those four disagree, the disagreement is the finding.

09

References

  1. GitLab, Gitaly design document gitlab-org/gitaly, living document since 2016. Checked 2026-09-06.
  2. GitLab, Gitaly README gitlab-org/gitaly. Checked 2026-09-06.
  3. GitLab, Scaling Git Handbook design document, 2026-06-16. Checked 2026-09-06.
  4. GitLab, Gitaly MR 7312, Praefect-to-Raft migration steps Opened 2024-09-30, closed unmerged. Checked 2026-09-06.
  5. GitLab, CI/CD Scaling Handbook design document, created 2021-01-21, approved 2021-04-26. Checked 2026-09-06.
  6. GitLab, Database Scalability: limit on-disk table size Handbook design document, 2021-06-23. Checked 2026-09-06.
  7. GitLab, CI/CD data time decay Handbook design document, 2021-09-10. Checked 2026-09-06.
  8. GitLab, Container registry metadata database Handbook design document, 2020-09-29, status implemented. Checked 2026-09-06.
  9. GitLab, ClickHouse usage at GitLab Handbook design document, 2023-02-02. Checked 2026-09-06.
  10. GitLab, Siphon Handbook design document, 2024-11-20. Checked 2026-09-06.
  11. GitLab, Next Runner Auto-scaling Architecture Handbook design document, 2022-01-19. Checked 2026-09-06.
  12. Docker, machine issue 4537 GitHub, 2018-07-13; repository archived 2025-07-18. Checked 2026-09-06.
  13. GitLab, Cells Handbook design document, created 2022-09-07. Checked 2026-09-06.
  14. GitLab, Cells 1.0 Handbook design document. Checked 2026-09-06.
  15. GitLab, Protocells Handbook design document, 2026-02-15. Checked 2026-09-06.
  16. GitLab, Cells ADR-001: routing technology Handbook decision record. Checked 2026-09-06.
  17. GitLab, Cells ADR-009: initial cell sizes Handbook decision record. Checked 2026-09-06.
  18. GitLab, Cells ADR-026: router library for path-based routing Handbook decision record, 2026-02-20. Checked 2026-09-06.
  19. GitLab, Rejected: stateless router with buffering requests Handbook design document, rejected. Checked 2026-09-06.
  20. GitLab, cells/http-router project Created 2024-02-15, last activity 2026-09-04. Checked 2026-09-06.
  21. GitLab, Rails monolith decomposition Handbook design document, created 2023-05-22, status proposed. Checked 2026-09-06.
  22. GitLab, Organization Handbook design document, 2023-04-05. Checked 2026-09-06.
  23. GitLab, Object storage: direct upload consolidation Handbook design document, 2021-11-18. Checked 2026-09-06.
  24. GitLab, runbooks service documentation gitlab-com/runbooks. Checked 2026-09-06.
  25. GitLab, Sidekiq sharding runbook gitlab-com/runbooks. Checked 2026-09-06.
  26. GitLab, CI mirrored tables runbook gitlab-com/runbooks. Checked 2026-09-06.
  27. GitLab, Gitaly administration documentation gitlab-org/gitlab. Checked 2026-09-06.
  28. GitLab, Using NFS with GitLab gitlab-org/gitlab. Checked 2026-09-06.
  29. GitLab, Incident review: 2022-11-30 site-wide outage gitlab-com/gl-infra/production. Checked 2026-09-06.
  30. GitLab, Incident review: Redis primary CPU saturation 2026-05-20. Checked 2026-09-06.
  31. GitLab, Incident review: Sidekiq queueing SLO violation 2026-03-11. Checked 2026-09-06.
  32. GitLab, Incident review: paused pgBouncer in Patroni sec cluster 2025-07-10. Checked 2026-09-06.
  33. GitLab, Incident review: slow Gitaly operations 2026-03-24. Checked 2026-09-06.
  34. GitLab, Incident review: Google Cloud outage 2025-06-12. Checked 2026-09-06.
  35. GitLab, Incident review: GitLab.com down 2025-12-05. Checked 2026-09-06.