What GitLabOps is
Link to section “What GitLabOps is”GitLabOps in 30 seconds
Link to section “GitLabOps in 30 seconds”GitLabOps is the work of running software delivery well on GitLab: designing .gitlab-ci.yml pipelines, operating the runners
that execute jobs, governing environments and deployments, wiring in GitLab’s scanners and registries, and, on GitLab Self-Managed,
keeping the instance healthy, backed up and upgraded.
Every push or merge request becomes a pipeline of jobs; a runner with matching tags runs each job in an executor (container, VM or Kubernetes pod), and deploy jobs record deployments to environments. GitLabOps keeps this loop fast, cheap, safe and dull.
Where to start. Beginners: the glossary, Lab 1. Engineers: the playbooks, reference implementation. Leaders: the decision framework, judgement call. Last verified 2026-10-16 against GitLab 19.x, when 19.5 was current and 19.4 and 19.3 received security fixes only[3].
At a glance
Link to section “At a glance”| Aspect | Summary |
|---|---|
| Goal | Fast, trusted feedback; gated, reversible deployments; a supported, affordable platform |
| People | Developers (the users), CI/CD engineers, runner operators, GitLab administrators, security engineers, environment owners |
| Prerequisites | Git, YAML, containers and Linux; for Self-Managed administration also PostgreSQL and backups |
| In scope | Pipelines and components; runners; environments, deployments and the agent for Kubernetes; scanners as configured; registries; self-managed upgrades |
| Out of scope | Principles (GitOps); culture and DORA (DevOps); release strategy (ReleaseOps); scanning policy (DevSecOps) |
| Outputs | A pipeline skeleton and pinned components; runner fleets by trust; a guarded promotion path; an upgrade calendar; a monthly scorecard |
| Term status | Vendor-specific (note above); product facts are dated because GitLab ships monthly[3] |
The problem it solves
Link to section “The problem it solves”Starting is easy; trouble arrives with scale and time:
- Waiting, not building. Jobs stay
pendingbecause no runner has the tags, the only matching runners are protected (they take only protected-branch and tag jobs), or the host’sconcurrentcap is reached[4][5]. - Duplicate work. Branch and merge-request pipelines without a guard run twice per push[6]; superseded pipelines run on
unless jobs are
interruptibleand auto-cancel is set[7]. - Leaky trust boundaries. A shell executor or privileged container on a shared runner lets any Developer reach the host[8]; fork merge requests can carry code that tries to steal secrets[9].
- Ungoverned deployments. By default Developers can modify environments, and an older deploy job can finish after a newer one[10].
- Platform drift. A lagging self-managed instance leaves the security-fix window; catching up means required upgrade stops and background migrations before each hop[3][11][12].
The cost of ignoring it (illustrative): 40 engineers waiting for 6 pipelines a day, 10 avoidable minutes each, lose 40 × 6 × 10 = 2,400 minutes, 40 engineer-hours, every working day.
What GitLabOps is not: boundaries and naming
Link to section “What GitLabOps is not: boundaries and naming”- Not GitOps. OpenGitOps defines GitOps by four principles: declarative, versioned and immutable, pulled automatically,
continuously reconciled[13]. A CI job running
kubectl applyis push-based; GitLab says “The GitOps workflow is recommended” and “You should not use a CI/CD workflow for production deployments.”[14] GitOps on GitLab (Flux plus the agent) is covered here; the principles are on GitOps. - Not a product, role or certification (FAQ).
- Not only administration. On GitLab.com or Dedicated you still own pipelines, runners, secrets, environments and cost[15].
Two misconceptions, corrected by GitLab: “Masking a CI/CD variable is not a guaranteed way to prevent malicious users from accessing variable values”[16]; and “The security risks are greater when using instance runners as they are available by default to all groups and projects”[4].
Where it comes from and where it sits
Link to section “Where it comes from and where it sits”Origins and evolution
Link to section “Origins and evolution”The word has no traceable origin. GitLab’s operational surface has a history that older tutorials still miss:
- 16.0: create a runner in the UI or API first, then register it with its
glrt-token[17]. - 16.2: the agent’s built-in GitOps deprecated in favour of Flux[18].
- Runner 17.1: Docker Autoscaler generally available, replacing Docker Machine[19].
- 17.5: Docker Machine “scheduled for removal in GitLab 20.0 (May 2027)”[20]; required stops fixed at x.2, x.5, x.8 and x.11[11].
- 18.0: registration tokens were not removed after all[21], but stay “not recommended”[17].
- 19.0: job tokens default to JWT format, the legacy format usable until 20.0[22].
- Current docs: the kaniko page is marked removed: “kaniko is no longer a maintained project”[23].
Glossary and mental model
Link to section “Glossary and mental model”Mental model: four loops, each failing in its own way. When someone says “the pipeline is slow”, first ask which loop is slow.
Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 135, "nodeSpacing": 14, "rankSpacing": 26, "padding": 8}}}%%
flowchart TB
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
subgraph L1["1 Configuration"]
direction LR
C["YAML and components merge into one pipeline graph"]:::core
FC["Fails as: duplicate pipelines, stage barriers, invalid YAML"]:::risk
C --- FC
end
subgraph L2["2 Scheduling"]
direction LR
S["A runner with matching tags, protection and free capacity takes each job"]:::core
FS["Fails as: jobs stuck pending, long queue time"]:::risk
S --- FS
end
subgraph L3["3 Delivery"]
direction LR
D["Deploy jobs or Flux change environments"]:::core
FD["Fails as: unapproved, outdated or unsigned deployments"]:::risk
D --- FD
end
subgraph L4["4 Platform, beneath loops 1 to 3"]
direction LR
P["GitLab version, capacity, backups"]:::neutral
FP["Fails as: API errors, slow Gitaly, missed security fixes"]:::risk
P --- FP
end
L1 --> L2 --> L3
L3 ~~~ L4
Text equivalent: Every change passes three loops in order. Configuration merges YAML and components into one pipeline graph and fails as duplicate pipelines, stage barriers or invalid YAML (the CI Lint API catches the last[24]). Scheduling hands each job to a runner with matching tags, protection and capacity, and fails as pending jobs. Delivery changes environments and fails as unapproved, outdated or unsigned deployments. The platform loop underpins all three and fails as API errors, slow Gitaly or missed security fixes.
- Stage,
needs: by default a job waits for every job in earlier stages;needsstarts it as soon as named jobs finish[7][25]. rules,workflow: decide whether a job, or the whole pipeline, is created;workflowprevents duplicates[6].- Merge-request pipeline: runs on every change to a merge request’s source branch and tests that branch alone[9]; merged results pipelines and merge trains (Premium, Ultimate) test the result of merging it into the target[26][27].
- CI/CD component: a versioned, reusable pipeline unit with typed inputs[28].
- Runner, executor: the process that requests and runs jobs; the executor (Docker, Docker Autoscaler, Instance and Kubernetes are primary) decides isolation[29].
- Runner scope, protected runner: instance, group or project scope decides who can use, and abuse, a runner[30]; a protected runner takes only protected-ref jobs[4].
- Protected environment, deployment tier: restricts who deploys and can require approvals (Premium, Ultimate); the tier (production, staging, testing, development, other) drives protection and DORA data[31][32].
- Job token, ID token:
CI_JOB_TOKENis valid only while the job runs, with the triggering user’s access[22]; an OIDC ID token (id_tokens) is exchanged for short-lived cloud credentials[33]. - Agent for Kubernetes:
agentkconnects out from the cluster to GitLab’s agent server[14]. - Required stop, background migration: a version you cannot skip; database work that must finish before the next hop[12].
How GitLabOps relates to other Ops
Link to section “How GitLabOps relates to other Ops”GitLabOps is a platform-specific layer beneath vendor-neutral disciplines: they supply the principles and policies, it applies them on GitLab. The boundaries are editorial distinctions chosen by this Atlas; no standard defines them.
| Neighbour | Choose that page when… |
|---|---|
| GitOps | You design reconciliation itself, independent of the Git host |
| DevOps | You need culture, value streams and the DORA measurement model |
| DevSecOps | You set security policy and decide which findings block a merge |
| ReleaseOps | You decide how to release (canaries, flags, rollback policy) |
| PlatformOps | GitLab is one component of an internal platform |
| TestOps | The problem is test signal and flaky tests, not pipeline mechanics |
| KubernetesOps | The problem is inside the cluster |
Principles and operating model
Link to section “Principles and operating model”Charter and principles
Link to section “Charter and principles”No official manifesto exists; this is this Atlas’s proposal.
- Pipelines are a product; developers are the users. Publish service levels for feedback time and reliability.
- Reuse through pinned components, “a specific commit SHA (preferred) or release version tag”[28].
- Isolation follows trust. Privileged work only on isolated, ephemeral runners limited to protected branches[8].
- Identity beats secrets. Allowlisted job tokens; ID tokens bound to project, ref and environment[22][33].
- Production is pulled or gated, and the pull source is guarded. Write access to the promoted artifact, or to its signing identity, is deploy access[14][32].
- Stay inside the security-fix window[3].
- Measure before tuning.
The assurance indicators turn these into testable invariants. The trade-offs remain: speed versus isolation, reuse versus team autonomy, GitLab-native features versus portability.
Fundamentals and building blocks
Link to section “Fundamentals and building blocks”Five building blocks:
- Control plane: Puma (web and API), Sidekiq (background jobs), Gitaly (Git repository access), PostgreSQL, Redis, object storage, the registry and the agent server[34]. GitLab runs it on GitLab.com and Dedicated[15]; you run it on Self-Managed.
- Configuration:
.gitlab-ci.yml, components, variables and project settings such as the 60-minute default job timeout[35]. - Execution: runner managers, executors and caches[36].
- Delivery: environments, deployments, the agent and Flux.
- Assurance: test and scan reports, metrics and audit events. Govern their retention too: job logs, artifacts (
expire_in[37]) and, on Self-Managed, backups withgitlab-secrets.jsonandgitlab.rb[38].
Roles, ownership and RACI
Link to section “Roles, ownership and RACI”| Activity | Developer | CI/CD engineer | Runner operator | GitLab admin | Security |
|---|---|---|---|---|---|
| A project’s pipeline | R/A | C | I | — | C |
| Shared components | C | R/A | C | — | C |
| Runners: create, size, retire | I | C | R/A | C | C |
| Protected environments, approvers | C | C | I | — | C (A: environment owner) |
| Scanners and policies | I | R | — | C | A |
| Upgrades and restore tests | I | C | C | R/A | I |
| “Jobs pending for everyone” | I | C | R/A | R (control plane) | — |
R = responsible, A = accountable, C = consulted, I = informed. A small team that runs fleets and components as an internal product while teams own their pipelines usually works (PlatformOps); a central team writing everyone’s YAML becomes a queue.
Lifecycle: Day 0, Day 1, Day 2
Link to section “Lifecycle: Day 0, Day 1, Day 2”| Phase | Work | Exit criteria |
|---|---|---|
| Day 0 | Offering and tier (merge trains, protected environments and DORA dashboards are paid[27][32][39]); sizing by requests per second[40]; runner topology; identity policy | A decision record per choice |
| Day 1 | Install; create and register runners[17]; publish components; migrate pilots | Pilots green two weeks; baselines taken |
| Day 2 | The cadence below: patches, minor upgrades, health, cost and clean-up reviews | In the security-fix window; SLOs met |
Operating model and governance
Link to section “Operating model and governance”| Cadence | Activity |
|---|---|
| Weekly, 30 min | Pipeline health review (percentiles, top failing jobs, retries, cost) → a few owned tickets |
| Twice monthly / monthly | Self-managed patches[3] / release notes and deprecations triage |
| Every 1–2 months | Minor upgrade through any required stop, rehearsed on a clone[11] |
| Quarterly | Timed restore test; fleet capacity and isolation review |
| Before May | Major-version readiness (20.0 is scheduled for 2027-05-20[3]) |
Governance escalates from conventions to configuration teams cannot edit (see the reuse comparison). A change to a shared component, runner image or fleet is a production change: review, canary group, rollback.
Architecture and workflow
Link to section “Architecture and workflow”Reference architecture
Link to section “Reference architecture”A logical reference for containers deployed to Kubernetes with your own runner fleets. The fleet split is this Atlas’s recommendation, derived from GitLab’s runner security guidance[8].
Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 115, "nodeSpacing": 18, "rankSpacing": 36, "padding": 8}}}%%
flowchart LR
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
FA["Fleet A, general and untrusted: autoscaler manager, one-job VMs"]:::core
FB["Fleet B, protected refs only: protected runner, release pods"]:::risk
CACHE[("Runner cache bucket")]:::neutral
subgraph CP["GitLab control plane"]
API["API and Sidekiq; artifacts in object storage"]:::core
REG["Container registry"]:::core
end
CL["Cloud APIs via OIDC"]:::risk
FA -->|"cache"| CACHE
FA -->|"jobs, artifacts"| API
FB -->|"jobs"| API
FB -->|"push"| REG
FB -->|"ID token"| CL
Text equivalent: Fleet A (general, untrusted jobs; one-job VMs from an autoscaler) writes to the runner cache bucket and exchanges jobs and artifacts with the GitLab API. Fleet B (protected refs only; release pods) requests jobs, pushes to the registry and exchanges OIDC ID tokens with cloud APIs. Red-tinted elements hold production-capable credentials.
| Component | Responsibility and control |
|---|---|
| Fleet A | Lint, test, scan, fork merge requests. Unprivileged; one job per VM (max_use_count = 1)[19]; no production variables |
| Fleet B | Protected-ref builds; publish, sign, promote. Protected runner[41]; restricted egress; its own cache (protected branches already use separate caches by default[42]) |
| Registry | Protected tag rule for production[43]; clean-up policies never delete protected tags[44] |
| Agent and Flux | Pull-based reconciliation with signature checks[45]; the agent manages cluster access and shows state, and reconciles immediately only for Git sources in the agent configuration project and public projects[46] |
| Cloud trust | OIDC trust policies on aud and sub; on Self-Managed and Dedicated, AWS can check nothing else (promotion path, step 3)[47] |
Runners and the agent connect out to GitLab, except receptive agents (Ultimate, Self-Managed, from 17.4)[14].
The promotion path: who can really deploy?
Link to section “The promotion path: who can really deploy?”Flux deploys whatever the production tag points to, so moving that tag is a production credential. Every job gets a registry user
“with read-write access to the container registry”[48], and “by default, users with the Developer, Maintainer, or Owner
role can push and delete image tags”[43]. Unguarded, a feature-branch job or a laptop could move production past every
approval.
Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 140, "nodeSpacing": 16, "rankSpacing": 30, "padding": 8}}}%%
flowchart TB
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
PUB["Fleet B publish job: manifest artifact :sha, digest recorded"]:::core
APR{{"Protected environment: an approver allows the deploy"}}:::decision
KMS["KMS signing key; its role accepts only the main, protected-production subject, requested by the deploy job alone"]:::risk
DEP["Fleet B deploy job: sign the digest, move the tag"]:::risk
ATK["A Developer's job or laptop"]:::neutral
TAG["Registry tag production; push limited by a protected tag rule"]:::core
FX{{"Flux: is the production digest validly signed?"}}:::decision
OK["Reconcile; agentk shows state in GitLab"]:::core
NO["Refuse: SourceVerified False, alert"]:::risk
PUB --> APR --> DEP
KMS -.->|"sign"| DEP
DEP --> TAG
ATK -.->|"blocked by the rule"| TAG
TAG --> FX
FX -->|"yes"| OK
FX -->|"no"| NO
Text equivalent: The publish job records the artifact digest; an approver allows the deployment; the deploy job signs the digest with a KMS key whose role accepts only the subject that this job alone requests, then moves the protected tag. A Developer’s job or laptop is blocked by the tag rule. Flux reconciles only a validly signed artifact; otherwise it refuses and alerts.
- Protected container tag for
^production$, push restricted to Maintainer (Free, generally available since 17.11; on Self-Managed it needs the registry metadata database)[43][49]. A CI push counts as the job’s user: the job token has “the same access level as the user that triggered the pipeline”, or ran the manual job[22], and in the 19.5 source the registry derives the denied tag patterns from that user’s project role[50]. So the deploy job must be run by a Maintainer or Owner, and a Maintainer’s feature-branch job passes the rule too: only step 2 stops an unsigned tag move. Rule changes apply once registry tokens expire (5 minutes by default, 15 minutes on GitLab.com)[43]; test after that. - Signature verification. Flux’s
OCIRepositoryrefuses anything not signed with the cosign key (spec.verify)[45]; the key sits in a cloud KMS[51] behind a role that accepts only this project’s ID tokens[47]. On GitLab.com, keyless Sigstore signing is an alternative[52]. - Bind the signing role to the production environment, not just
main. The default subject names only project, ref type and ref, so everymainjob, Fleet A’s included, gets a token the role accepts. AWS cannot checkrunner_idor environment claims (on Self-Managed and Dedicated onlysubandaud)[47], so addenvironment_protectedanddeployment_tierto the project’sci_id_token_sub_claim_components(18.7 and later) and require the deploy job’s full subject[53][33]. Confirm the string in a test project (the docs do not print it); the project’s other trust policies see it too. - Give that subject to the deploy job only. Environment fields prove that a job named the protected production environment,
not that anyone approved it. Approvals block “jobs deploying to the environment”[32], but a job with
environment:actionprepare,verifyoraccess“does not trigger deployments”[7], andenvironment_actioncannot be a subject component[53]. GitLab’s own change says “approval is not required when the job only accesses the environment”, while an open issue says such jobs still need deployer access[54]. So a post-deploy smoke test onmainthat names production and requests an ID token could sign without approval. Allowid_tokensonly on the deploy job, enforce it through code-owner review of CI files[55], and test that averifyjob onmaincannot assume the role. - Optionally, a separate CD project whose members are only release managers[10].
Residual risks. Anyone the tag rule allows can re-point production to an older signed release; and a reviewed CI change can
still add a job that signs.
End-to-end workflow: merge request to production
Link to section “End-to-end workflow: merge request to production”Diagram source (Mermaid)
---
config:
sequence:
actorMargin: 44
width: 96
diagramMarginX: 6
wrap: true
boxMargin: 6
messageMargin: 30
---
sequenceDiagram
participant Dev as Developer
participant GL as GitLab
participant RN as Runners
participant Ap as Approver
participant FX as Flux
Dev->>GL: 1. Open MR
RN->>GL: 2. Fleet A tests
opt
Note over Dev,GL: Pipeline fails
GL-->>Dev: 3. Failed job
Dev->>GL: 4. Push fix
end
Dev->>GL: 5. Merge
RN->>GL: 6. Fleet B builds
Ap->>GL: 7. Approve
RN->>GL: 8. Sign, move tag
FX->>GL: 9. Verify, pull
opt
Note over Ap,FX: Health checks fail
Ap->>GL: 10. Rollback job
FX->>GL: 11. Pull previous
end
Text equivalent: (1–2) A merge request gets one pipeline, run on Fleet A. (3–4) If it fails, the developer pushes a fix, which cancels the superseded jobs. (5–6) Once green, it merges and protected Fleet B builds and records the digest. (7) An approver other than the triggerer approves. (8) The deploy job, serialised by a resource group, signs and moves the tag. (9) Flux verifies and reconciles. (10–11) If health checks fail, a rollback job re-tags the previous signed release.
By default “the user who triggers a deployment pipeline can’t also approve the deployment job”; leave “Allow pipeline triggerer to approve deployment” off where separation of duties matters[32]. Each decision on this path has an owner and leaves a record:
| Decision | Owner | Where it is recorded |
|---|---|---|
| Which pipeline runs | CI/CD engineer | workflow:rules in .gitlab-ci.yml |
| Which runner may take a job | Runner operator | Tags and protection in the runner settings |
| May this deployment proceed? | Environment owner | Approval and deployment record |
| Roll back or roll forward | Service owner | Rollback job; policy on ReleaseOps |
GitLab’s DORA dashboards count only successful deployments to production-tier environments, so tier them correctly[39].
Practices, frameworks and tools
Link to section “Practices, frameworks and tools”Core practices and playbooks
Link to section “Core practices and playbooks”Each playbook ends with an acceptance test and a rollback. Also review clean-up policies and artifact expiry monthly[44].
Playbook 1: a duplicate-free pipeline skeleton
Link to section “Playbook 1: a duplicate-free pipeline skeleton”workflow:rulesthat run merge-request pipelines, suppress branch pipelines while a merge request is open, and keep default-branch and tag pipelines[6]. Put them in.gitlab-ci.ymlitself: rules inside an included component do not enable merge-request pipelines[9].workflow:auto_cancel:on_new_commit: interruptible; testsinterruptible: true, deploysfalse[7].needsinstead of stage barriers; lint fast and first[25].rules:changeswithcompare_toon expensive jobs, becauserules:changesis true for new branches and for pipelines without a push event[7].- With GitLab’s security templates,
AST_ENABLE_MR_PIPELINES: "true"(18.0 and later): “By default, the application security jobs are configured to run for branch pipelines only”, so step 1 would silently drop them from merge requests[56]. - A Lint API
dry_runbefore merging[24].
Acceptance: one pipeline per push, scans included; a second push cancels the first’s interruptible jobs. Rollback: revert; each pipeline uses its own commit’s configuration.
Playbook 2: runner fleets by trust
Link to section “Playbook 2: runner fleets by trust”- Inventory runners (scope, executor, tags, protection, privileged flag, last contact); retire unknown ones.
- Define fleets by trust: general (unprivileged, ephemeral), protected (release credentials), special hardware[57].
- Create runners as code:
POST /user/runnerswithtag_list,run_untagged=falseand, for the protected fleet,access_level=ref_protected; the token is shown once, so store it in a secret manager[41]. - Autoscale with the Docker Autoscaler or the Kubernetes executor, with at least two managers[19][58][57].
- Scrape
/metricsfrom a private network only, because it has no authorisation[59]; keep runner and GitLab versions aligned[60].
Acceptance: every fleet has an owner, capacity target and queue-time SLO; no privileged runner takes unprotected jobs. Rollback: pause the new runner, unpause the old; tags switch traffic per project.
Playbook 3: secrets and identity hygiene
Link to section “Playbook 3: secrets and identity hygiene”- Classify variables: configuration, low-risk tokens (masked and hidden), production secrets (move out).
- Cloud access through
id_tokens, a service-specificaud, and trust policies onsubplusproject_id(a separate key where supported, otherwise the firstsubcomponent, 19.1 and later), never user or email claims[33][53]. - Keep the job token allowlist: “By default, the allowlist of any project only includes itself”[22].
- Production variables protected and scoped to the production environment[16].
- With Ultimate, secret push protection, knowing it scans diffs only, skips files over 1 MiB and accepts the push on timeout[61]; pipeline secret detection is in every tier[62].
Acceptance: no long-lived cloud keys in variables. Rollback: keep old credentials disabled, not deleted, for a release cycle.
Playbook 4: deployment guardrails
Link to section “Playbook 4: deployment guardrails”environment:nameand an explicitdeployment_tieron every deploy job[31].- Protected production environments with approvals (Premium, Ultimate); group-level protection by tier covers new projects[32].
resource_group: production[63] and “prevent outdated deployment jobs”[10].- Flux with the agent for Kubernetes production; CI access through
ci_accessonly to non-production environments, impersonating the job[64]. - Guard the promotion path (tag rule, signatures, deploy-job-only signing subject).
- Decide rollback: “Allow job retries for rollback deployments” is on by default; GitLab advises disabling it in sensitive projects, where “you must run a new pipeline with a previous commit”[10].
Acceptance: a Developer’s tag move is blocked, Flux refuses an unsigned artifact, and a verify job on main cannot assume
the signing role. Rollback: only through review; deleting an approval rule can lose its history[32].
Playbook 5: self-managed upgrades
Link to section “Playbook 5: self-managed upgrades”- A minor upgrade every one to two months through any required stop[11], plus the patch releases. Security fixes cover the current release and the two before it, so an instance installed on release N the day it ships is covered until N+3 ships, about three months: a quarterly rhythm leaves no slack[3].
- Before each hop: rehearse on a clone, back up including secrets and configuration, confirm background migrations finished (runbook)[65][38].
- After:
gitlab:check,gitlab:doctor:secrets, sign-in, clone and push, runner pickup, registry push and pull; then upgrade runners and agents[60].
Acceptance: checks pass; current or previous release. Rollback: a restore needs a backup of exactly the same version and edition[38], so back up and snapshot immediately before each hop.
Methods, standards and frameworks
Link to section “Methods, standards and frameworks”GitLabOps has no standard of its own. It borrows OpenGitOps as a test of whether a setup is GitOps[13]; DORA’s metrics (five in the guide updated 2026-01-05; GitLab’s dashboards implement the earlier four)[66][39]; the OWASP Top 10 CI/CD Security Risks as a threat checklist[67]; and NIST SP 800-204D where an organisation or contract adopts it[68]. Vendor rules such as required stops are mandatory; reference architectures are advice[40].
Tooling landscape
Link to section “Tooling landscape”| Job | Options (October 2026) | Limit |
|---|---|---|
| Validate | CI Lint API dry_run[24] |
Simulates creation, not runtime |
| Reuse | Components, CI/CD Catalog[28]; templates[69] | Same-name jobs merge silently |
| Build images | Rootless BuildKit, Buildah, Docker-in-Docker[70][71]; kaniko removed[23] | Rootless needs user namespaces |
| Observe | Runner metrics[59]; CI/CD analytics[72] | Some panels need ClickHouse |
| Executor[29] | Status | Good for | Watch out for |
|---|---|---|---|
| Docker | Primary | Builds on static hosts | The mounted Docker socket shares the host daemon[73] |
| Docker Autoscaler | Primary | Elastic one-job VMs | Cloud quotas; VM start-up |
| Instance | Primary; clean only conditionally | Full OS or devices | Making hosts disposable is your job |
| Kubernetes | Primary | A pod per job | Requests, limits, privileged pods |
| Shell | Maintenance mode | Trusted single-purpose hosts | “Use it only for running trusted builds”[8] |
| Docker Machine | Deprecated; removal in 20.0[20] | Nothing new | Migrate |
Automation and AI
Link to section “Automation and AI”Automate the machinery, not the trust decisions: runner creation and rotation through the API, Lint dry runs on shared-component changes, reports of stale runners and unpinned components, upgrade gates (runbook). GitLab Duo’s Root Cause Analysis reads the last 100,000 characters of a failed job’s log and proposes a fix (Duo add-on; not for trigger jobs or downstream pipelines)[74]; approve the log sharing first, lint any suggested YAML, and let no assistant change protected pipelines, runners or environment protection without human approval[67].
GitLabOps in practice
Link to section “GitLabOps in practice”Reference implementation
Link to section “Reference implementation”A representative design, not a recipe: GitLab 19.x; a containerised Node.js service; Kubernetes production with Flux; AWS KMS
for the signing key; Premium or Ultimate for approvals (on Free the signing role can be bound only to main). All hosts, IDs and
tokens are placeholders.
Example status for this section: YAML (Atlas validator), TOML (smol-toml 1.3.1) and bash (bash -n) syntax-checked; nothing
executed against a live GitLab instance, registry, KMS key or cluster.
1. A reusable build component
Link to section “1. A reusable build component”templates/build-image/template.yml in a project such as platform/ci-components, following GitLab’s rootless BuildKit
job[70]: no privileged container. Rootless BuildKit still needs “a runner that permits the system calls BuildKit uses to
create user namespaces and mount points”; where it fails, GitLab suggests Buildah, so test it on each fleet.
spec:
inputs:
stage:
default: build
description: Stage in which the build job runs
image_name:
description: Registry path of the image, without a tag
context:
default: "."
description: Build context directory
runner_tag:
default: protected-build
description: Tag of the runner fleet allowed to build release images
---
build-image:
stage: $[[ inputs.stage ]]
image:
name: moby/buildkit:rootless # pin by digest in production
entrypoint: [""]
tags: ["$[[ inputs.runner_tag ]]"]
interruptible: false
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > ~/.docker/config.json
script:
- |
buildctl-daemonless.sh build \
--frontend dockerfile.v0 \
--local context=$[[ inputs.context ]] \
--local dockerfile=$[[ inputs.context ]] \
--output type=image,name=$[[ inputs.image_name ]]:$CI_COMMIT_SHA,push=true
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
2. The project pipeline
Link to section “2. The project pipeline”# .gitlab-ci.yml (illustrative, GitLab 19.x)
include:
- component: $CI_SERVER_FQDN/platform/ci-components/build-image@1.4.2
inputs:
stage: build
image_name: $CI_REGISTRY_IMAGE
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
stages: [lint, test, build, publish, deploy]
default:
interruptible: true
tags: [linux-general]
retry:
max: 2
when: [runner_system_failure, runner_interrupted] # infrastructure faults only, never script failures
variables:
NODE_IMAGE: node:22-bookworm-slim # pin by digest in production
MANIFEST_REPO: $CI_REGISTRY_IMAGE/manifests
TOOLS_IMAGE: $CI_REGISTRY/platform/images/release-tools:3 # internally vetted: flux, cosign, jq, AWS CLI
AST_ENABLE_MR_PIPELINES: "true" # run the security templates in MR pipelines too (18.0 and later)
.npm-cache: &npm-cache
key:
files: [package-lock.json]
paths: [.npm/]
lint:
stage: lint
image: $NODE_IMAGE
cache:
<<: *npm-cache
policy: pull
script:
- npm ci --cache .npm --prefer-offline
- npm run lint
unit-test:
stage: test
needs: [] # start immediately instead of waiting for the lint stage
image: $NODE_IMAGE
cache:
<<: *npm-cache
script:
- npm ci --cache .npm --prefer-offline
- npm test
artifacts:
when: always
reports:
junit: junit.xml
expire_in: 7 days
publish-manifests:
stage: publish
image: $TOOLS_IMAGE
tags: [protected-build]
interruptible: false
needs: [build-image]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- >
flux push artifact "oci://$MANIFEST_REPO:$CI_COMMIT_SHORT_SHA" --path=./deploy
--source="$CI_PROJECT_URL" --revision="$CI_COMMIT_BRANCH@sha1:$CI_COMMIT_SHA"
--creds="$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD" --output json > push.json
- echo "MANIFEST_DIGEST=$(jq -r .digest push.json)" > manifest.env
artifacts:
reports:
dotenv: manifest.env # hands the digest to deploy-production
deploy-production:
stage: deploy
image: $TOOLS_IMAGE
tags: [protected-build]
interruptible: false
needs: [publish-manifests]
resource_group: production
environment:
name: production
deployment_tier: production
url: https://www.example.com
id_tokens:
SIGNER_ID_TOKEN:
aud: https://gitlab.example.com
variables:
# IAM trust policy: StringEquals on sub = this job's subject incl. environment fields (promotion path, steps 3-4).
# Only this job declares id_tokens.
SIGNER_ROLE_ARN: arn:aws:iam::ACCOUNT_ID:role/manifest-signer
COSIGN_KEY: awskms:///alias/manifest-signer
COSIGN_YES: "true"
AWS_REGION: eu-west-1
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # run by a Maintainer: the production tag rule checks the job's user
script:
- >
creds=$(aws sts assume-role-with-web-identity --role-arn "$SIGNER_ROLE_ARN"
--role-session-name "deploy-$CI_PIPELINE_ID" --web-identity-token "$SIGNER_ID_TOKEN"
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)
- export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $creds)
- printf '%s' "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cosign sign --key "$COSIGN_KEY" "$MANIFEST_REPO@$MANIFEST_DIGEST" # transparency-log upload: decide, see below
- flux tag artifact "oci://$MANIFEST_REPO:$CI_COMMIT_SHORT_SHA" --tag production --creds="$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD"
- cosign verify --key "$COSIGN_KEY" "$MANIFEST_REPO:production" > /dev/null # fail if the tag is not on a signed digest
Why each piece. The workflow, interruptible, needs and scanner settings are Playbook 1.
Retries cover infrastructure failures only, so flaky tests stay visible; the JUnit report feeds the merge-request summary without
changing job status[75]. The publish job hands the artifact digest on through a dotenv report[76][7]. The
deploy job, the only one with id_tokens, exchanges its token for short-lived AWS credentials[47], signs the recorded digest
and moves the tag under resource_group[63]; if someone re-points :<sha> in between, production lands on an
unsigned digest, the final cosign verify fails and Flux refuses it.
Transparency log: decide, do not inherit. cosign sign records each signature in a transparency log unless told not to: cosign
2 in the public rekor.sigstore.dev, so a private registry gets a public entry per release; cosign 3 in the services of a
TUF-provided signing configuration unless you pass your own, keeping --tlog-upload=false only as a deprecated flag (“prefer using a
–signing-config file with no transparency log services”)[77]. Air-gapped instances need their own log or none; without
an entry, verify with --insecure-ignore-tlog=true[77]. Flux’s public-key verification sets IgnoreTlog = true
(source-controller v1.9.6, with a comment calling it an oversight; re-check after upgrades)[78]. Status: defaults read
from the cosign 2.6.5 and 3.1.3 and Flux sources; not executed.
Settings outside YAML: protected main with code-owner approval for CI files[55]; protected production with
approvers and the triggerer-approval setting off[32]; the ^production$ tag rule, so a Maintainer runs the deploy job[43]; the ID-token subject
components[53]; “prevent outdated deployment jobs” and a deliberate rollback-retry choice[10].
3. Runner fleets
Link to section “3. Runner fleets”Fleet A: ephemeral VMs with the Docker Autoscaler and the AWS fleeting plugin[19][5]. Example status: TOML syntax-checked; not executed.
concurrent = 40
check_interval = 3
log_level = "info"
listen_address = "10.0.12.5:9252" # private interface only: /metrics has no authentication
[[runners]]
name = "fleet-a-general"
url = "https://gitlab.example.com"
token = "glrt-REPLACE_WITH_TOKEN_FROM_SECRET_STORE"
executor = "docker-autoscaler"
limit = 40
output_limit = 8192
[runners.docker]
image = "alpine:3.20"
privileged = false
pull_policy = "always"
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "example-runner-cache"
BucketLocation = "eu-west-1"
AuthenticationType = "iam"
[runners.autoscaler]
plugin = "aws"
capacity_per_instance = 1
max_use_count = 1
max_instances = 40
[runners.autoscaler.plugin_config]
name = "fleet-a-general-asg"
[runners.autoscaler.connector_config]
username = "ec2-user"
use_external_addr = false
[[runners.autoscaler.policy]]
idle_count = 0
idle_time = "0s"
periods = ["* * * * *"]
[[runners.autoscaler.policy]]
idle_count = 6
idle_time = "20m0s"
periods = ["* 7-19 * * mon-fri"]
timezone = "Europe/Berlin"
idle_count counts idle job capacity, not machines, and the last matching policy wins[5]; pull_policy = "always"
follows GitLab’s advice for shared runners[8]. Fleet B runs protected builds in a dedicated namespace via the Helm
chart[58]; protection is not a config.toml setting but the runner’s access_level=ref_protected[41].
# values-fleet-b.yaml for the gitlab/gitlab-runner Helm chart (illustrative)
gitlabUrl: https://gitlab.example.com
runnerToken: "<set-at-install-from-your-secret-store>"
rbac:
create: true
concurrent: 10
runners:
config: |
[[runners]]
[runners.kubernetes]
namespace = "ci-protected"
image = "alpine:3.20"
privileged = false
cpu_request = "500m"
memory_request = "1Gi"
cpu_limit = "2"
memory_limit = "4Gi"
[runners.kubernetes.node_selector]
"workload" = "ci-protected"
4. Flux for production, the agent for staging
Link to section “4. Flux for production, the agent for staging”Flux follows production and verifies its signature first[45]; the key owner publishes the public key to the cluster[51]. Example status: bash -n syntax-checked; not executed.
cosign public-key --key awskms:///alias/manifest-signer > cosign.pub
kubectl -n flux-system create secret generic manifest-signer-pub --from-file=cosign.pub=cosign.pub
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: web
namespace: flux-system
spec:
interval: 5m
url: oci://registry.gitlab.example.com/shop/web/manifests
ref:
tag: production
secretRef:
name: gitlab-registry-pull # read-only registry credential
verify:
provider: cosign
secretRef:
name: manifest-signer-pub # unsigned or wrongly signed artifacts are refused
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: web
namespace: flux-system
spec:
interval: 10m
sourceRef:
kind: OCIRepository
name: web
path: ./
prune: true
wait: true
timeout: 5m
Staging may take pushes through the agent, limited to those environments and impersonating the CI job; without access_as[64], a job
inherits the agent’s full permissions:
# .gitlab/agents/staging-agent/config.yaml in the agent configuration project
ci_access:
projects:
- id: shop/web
environments:
- staging
- review/*
access_as:
ci_job: {}
---
# RoleBinding in the staging namespace: CI jobs appear in the group gitlab:ci_job
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitlab-ci-jobs-deploy
namespace: web-staging
subjects:
- kind: Group
name: gitlab:ci_job
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit # broad but namespace-bound; narrow it per project in real use
apiGroup: rbac.authorization.k8s.io
With pull delivery the deployment record marks the promotion, not the finished rollout; accurate DORA data may need the Deployments API, for example called from a Flux notification[39].
Hands-on labs
Link to section “Hands-on labs”Three labs, none executed here (commands are syntax-checked against the cited docs); use a test project, never production. A Flux bootstrap lab belongs with the reconciliation principles on GitOps.
Lab 1 (beginner): what your rules do, with no job run
Link to section “Lab 1 (beginner): what your rules do, with no job run”Objective. See which jobs a pipeline would contain per ref, with the CI Lint API’s dry run[24]. Prerequisites. A
project you own (GitLab.com Free is enough), an API token, curl, jq. Cost and risk. None. Commit this to main, then create
a branch feature-x:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
stages: [lint, test, deploy]
lint:
stage: lint
script: echo "lint"
unit-test:
stage: test
needs: []
script: echo "test"
deploy-docs:
stage: deploy
script: echo "deploy"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Run the dry run for main, then for feature-x. Example status: bash -n syntax-checked; not executed.
#!/usr/bin/env bash
set -euo pipefail
: "${GITLAB_URL:?}" "${GITLAB_TOKEN:?}" "${PROJECT_ID:?}"
REF="${1:-main}"
REQ=$(mktemp)
jq -n --rawfile content .gitlab-ci.yml --arg ref "${REF}" \
'{content: $content, dry_run: true, include_jobs: true, ref: $ref}' > "${REQ}"
curl --silent --fail --request POST \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
--header "Content-Type: application/json" \
--data @"${REQ}" \
--url "${GITLAB_URL}/api/v4/projects/${PROJECT_ID}/ci/lint" \
| jq '{valid, errors, warnings, jobs: [.jobs[]?.name]}'
rm -f "${REQ}"
Expected output. "valid": true; lint, unit-test and deploy-docs on main, no deploy-docs on feature-x.
Validation. Delete needs: []: the job list does not change, because needs changes when jobs start, not whether they
exist. Troubleshooting. 401/403: token scope or role. Teardown. Revoke the token; delete the lab project.
Lab 2 (intermediate): your own runner, queueing
Link to section “Lab 2 (intermediate): your own runner, queueing”Objective. Register a project runner, expose its metrics, and watch queueing when capacity is below demand.
Prerequisites. A disposable Linux VM with Docker; Maintainer on a project. Cost and risk. The VM; mounting the Docker socket
“delegates full control over the Docker daemon” to the runner container[73], so never use your workstation.
Create a project runner (Settings > CI/CD > Runners) tagged lab-docker, “Run untagged” off, and copy its glrt- token[17]. Example status: bash -n syntax-checked; not executed.
#!/usr/bin/env bash
set -euo pipefail
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to the glrt- token}"
# Runner container; metrics published on the host loopback only.
docker run -d --name gitlab-runner --restart always \
-p 127.0.0.1:9252:9252 \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
# Register with the authentication token (no tags or locking flags: those were set when the runner was created).
docker run --rm -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner:latest register \
--non-interactive --url "https://gitlab.com/" --token "${RUNNER_TOKEN}" \
--executor docker --docker-image alpine:3.20 --description lab-docker
# Expose metrics inside the container, then restart to be sure the change is loaded.
sudo sed -i '1i listen_address = "0.0.0.0:9252"' /srv/gitlab-runner/config/config.toml
grep -E '^(concurrent|listen_address)' /srv/gitlab-runner/config/config.toml
docker restart gitlab-runner
curl -s http://127.0.0.1:9252/metrics | grep -E '^gitlab_runner_(concurrent|limit|version_info)'
Set concurrent = 1 in config.toml, restart the container and push:
queue-demo:
tags: [lab-docker]
image: alpine:3.20
parallel: 3
script:
- sleep 60
Expected output. The three jobs run one after another; with concurrent = 3 they overlap. Validation. Later jobs show
longer queued times; gitlab_runner_concurrent reports 1, then 3. Troubleshooting. Stuck
pending: a tag typo or “Run untagged”; 410 Gone - runner registration disallowed: a legacy registration token[17].
Teardown. Delete the runner in the UI (invalidating its token), docker rm -f gitlab-runner, remove /srv/gitlab-runner (it
holds the token), destroy the VM.
Lab 3 (advanced): a before-and-after experiment
Link to section “Lab 3 (advanced): a before-and-after experiment”Objective. Measure merge-request feedback time, change one thing, and decide from data. Prerequisites. Python 3.8+, a
read-only token, a week of merge-request pipelines. Cost and risk. Read-only API calls, one per pipeline, so keep the window
small on busy projects. Pipeline duration “excludes … any pending (queue) time”[79], so the script reports wall-clock
feedback (finished_at − created_at), busy time and the wait in between; the API page’s own example pipeline hides about 3 seconds
of such wait[80].
Example status: syntax-checked; the filtering, pagination, timestamp and percentile logic was exercised offline with a stubbed
api() and a hand-written sample that includes the API page’s example pipeline; not run against a live API.
#!/usr/bin/env python3
"""Merge-request pipeline health from the GitLab Pipelines API (read-only).
Usage: GITLAB_URL=https://gitlab.example.com GITLAB_TOKEN=<read-only token> \
python3 pipeline_health.py <project id or path> [days] [source]
feedback_s = finished_at - created_at (wall clock, includes every wait)
busy_s = duration (job running time; GitLab excludes queue time)
"""
import json
import math
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
def api(path, params):
base = os.environ["GITLAB_URL"].rstrip("/")
url = f"{base}/api/v4{path}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": os.environ["GITLAB_TOKEN"]})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
def ts(value):
"""Parse GitLab timestamps such as 2022-09-21T01:05:07.200Z."""
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def percentile(values, pct):
"""Nearest-rank percentile: smallest value with at least pct % of the values at or below it."""
if not values:
return None
ordered = sorted(values)
rank = max(1, math.ceil(pct / 100 * len(ordered)))
return ordered[rank - 1]
def summarise(details):
finished = [d for d in details if d.get("status") in ("success", "failed")]
result = {"considered": len(finished)}
if finished:
ok = sum(1 for d in finished if d["status"] == "success")
result["success_rate"] = round(ok / len(finished), 3)
series = {"feedback_s": [], "busy_s": [], "queued_s": [], "in_pipeline_wait_s": []}
for d in finished:
if not (d.get("created_at") and d.get("finished_at")):
continue
wall = (ts(d["finished_at"]) - ts(d["created_at"])).total_seconds()
series["feedback_s"].append(wall)
busy, queued = d.get("duration"), d.get("queued_duration")
if busy is not None:
series["busy_s"].append(busy)
if queued is not None:
series["queued_s"].append(queued)
if busy is not None and queued is not None:
series["in_pipeline_wait_s"].append(max(0.0, wall - busy - queued))
for name, values in series.items():
if values:
result[name] = {"p50": percentile(values, 50), "p95": percentile(values, 95), "max": max(values)}
return result
def main():
project = urllib.parse.quote(sys.argv[1], safe="")
days = int(sys.argv[2]) if len(sys.argv) > 2 else 7
source = sys.argv[3] if len(sys.argv) > 3 else "merge_request_event"
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
listed, page = [], 1
while True:
batch = api(f"/projects/{project}/pipelines",
{"updated_after": since, "source": source, "per_page": 100, "page": page})
if not batch:
break
listed.extend(batch)
page += 1
# Filter again client-side, in case the server ignores an unknown source value.
wanted = [p for p in listed if p.get("source") == source and p.get("status") in ("success", "failed")]
details = [api(f"/projects/{project}/pipelines/{p['id']}", {}) for p in wanted]
print(json.dumps(summarise(details), indent=2))
if __name__ == "__main__":
main()
Take a 7-day baseline, make one change, measure 7 days later. Expected output. JSON with considered, success_rate and
p50/p95/max per series. Validation. Spot-check pipelines in the UI; CI/CD analytics medians should read lower than feedback_s[72]. A large in_pipeline_wait_s points at the fleet. Troubleshooting. HTTP 404: use the numeric ID. Teardown.
Revoke the token.
Use cases and scenarios
Link to section “Use cases and scenarios”Illustrative composites, not reports of named organisations.
| Scenario | Approach | Pitfall | Trade-off; success metric |
|---|---|---|---|
| Monorepo, 30 services, 45-minute pipelines | A child pipeline per service with rules:changes and compare_to; libs/ changes trigger every dependant; at most two child levels[81] |
Without compare_to, every new branch runs every child[7] |
More configuration; p95 feedback < 15 min, no more escaped defects |
| Regulated payments releases | Group-level protected production tier, approvals, triggerer barred[32]; a separate CD project of release managers[10]; the signing subject on its deploy job only; rollback retries off |
A deploy job run by a release manager with only the Developer role fails at the Maintainer tag rule | Longer lead time; every production change, rollbacks included, has an approval record |
| Open source with fork merge requests | Fork pipelines run in the fork; parent pipelines only after reading the diff[9]; an unprivileged ephemeral fleet | A parent-project run gives the fork’s CI configuration the parent’s variables and runners[9] | Slower first feedback; no unreviewed parent pipeline |
| Self-managed upgrade debt | Playbook 5 hop by hop, then a one-to-two-month rhythm | Gates on versions before 19.5 cannot use wait[12] |
Several windows; back inside the security window |
Measure, secure and pay
Link to section “Measure, secure and pay”Metrics and KPIs
Link to section “Metrics and KPIs”Measure the pipeline (what developers feel), the fleet (why) and the platform; DORA outcomes belong to DevOps.
| KPI | Definition and source | Pitfall |
|---|---|---|
| MR feedback time p50, p95 | finished_at − created_at per pipeline (Lab 3)[80] |
duration excludes every queue wait[79] |
| Job queue time p95 | gitlab_runner_job_queue_duration_seconds histogram[57] |
Averages hide the morning peak |
| Success rate | success ÷ (success + failed), cancellations excluded | Retries manufacture “green” |
| Fleet saturation | Running jobs ÷ concurrent or limit[57] |
100 % means queueing, not efficiency |
| Cost per successful pipeline | (runners + storage + compute minutes) ÷ successful pipelines[82] | Rewards skipping tests if used alone |
| Version lag | Minor releases behind current[3] | “Patched” yet outside the window |
Worked example (illustrative). A week of 1,200 merge-request pipelines: 1,000 succeeded, 150 failed, 50 auto-cancelled. Success
rate = 1,000 ÷ 1,150 = 87.0 % (83.3 % if cancellations counted as failures). On hosted medium runners (cost factor 2) at 30
job-minutes each: 1,200 × 30 × 2 = 72,000 compute minutes[82]; at price P, cost per successful pipeline = 72 P. A
p95 queue of 4.5 minutes plus a p95 duration of 22 minutes is not a p95 feedback time of 26.5: percentiles do not add, and both
miss waits between stages. Pair every speed target with a quality guard.
Fleet signals in PromQL (metric names from GitLab’s runner docs; the third query is the fleet guide’s own):
# p95 job queue time across a fleet, 15-minute window (histogram documented in the fleet guide)
histogram_quantile(0.95, sum by (le) (rate(gitlab_runner_job_queue_duration_seconds_bucket[15m])))
# Saturation: running jobs as a share of configured concurrency
sum(gitlab_runner_jobs) / sum(gitlab_runner_concurrent)
# Autoscaler start-up: p95 machine creation time
histogram_quantile(0.95, sum by (le) (rate(gitlab_runner_autoscaling_machine_creation_duration_seconds_bucket[5m])))
Quality, security, privacy and compliance
Link to section “Quality, security, privacy and compliance”Most GitLabOps security work is about who can make which job run where, with which identity. The pipeline-specific OWASP CI/CD risks[67], mapped to GitLab controls (identity reviews, dependency pinning, third-party services and logging are general practice on DevSecOps):
| Risk | GitLab controls |
|---|---|
| SEC-4 poisoned pipeline execution | Protected variables only on protected refs[16]; read fork diffs first[9]; code owners for CI files[55]; separate protected caches[42]; CI file in another project[35] |
| SEC-1 insufficient flow control | Protected branches, environments[32] and container tags[43]; the signing subject on the deploy job only |
| SEC-5 pipeline-based access control | Protected runners[4]; ci_access with access_as: ci_job[64]; job token allowlist[22] |
| SEC-6 credential hygiene | OIDC with tight aud and sub[33]; push protection (Ultimate)[61] |
| SEC-7 insecure system configuration | Privileged only on isolated, ephemeral, protected runners; shell only for trusted builds[8] |
| SEC-9 artifact integrity | Sign in the deploy job, verify in Flux[45] |
Runner authentication tokens are credentials: “users with access to the runner authentication token can clone a runner and submit false jobs”[4]. Keep personal data out of fixtures and set artifact expiry deliberately[37]. GitLab produces evidence (approvals, deployments, audit events, scans), not compliance (ComplianceOps).
Cost, capacity and sustainability
Link to section “Cost, capacity and sustainability”Cost drivers. Hosted runners: duration ÷ 60 × cost factor, with Linux factors 1, 2, 3, 6 and 12 from small to 2xlarge, and
sizes above medium on Premium or Ultimate only (October 2026)[82][83]. Own runners: VM hours including idle
capacity, storage, egress, operators. Self-Managed adds the control plane and upgrade labour.
Capacity with Little’s law (L = λ × W[84]) on job slots, where W includes VM start-up and teardown when
max_use_count = 1. Illustrative: 300 jobs an hour at peak (λ = 5 a minute) × (6 minutes run + about 1 minute overhead) = 35 busy
slots, five more than run time suggests; add burst headroom (1.3 × ≈ 45) and calibrate against p95 queue time. High queue time
with low saturation points at tags or protection instead. Idle capacity (idle_count) buys start-up latency with VM hours; time-box
it to working hours[5].
Efficiency levers: remove redundant work and stage barriers (Playbook 1); right-size machines, since over-provisioned runners waste money and under-provisioned ones waste developer time[25]; lock-file cache keys in a shared bucket[42]; artifact expiry and registry clean-up. With the registry metadata database, online garbage collection removes unreferenced data after 24 hours; with legacy metadata, schedule offline collection. Never run third-party garbage collection with the metadata database: it “will delete data associated with tagged images”[49]. We found no reliable carbon figures for CI; instance-hours are a proxy (GreenOps).
Assurance of the operating model
Link to section “Assurance of the operating model”Does the operating model still hold? Check monthly with read-only API jobs; healthy is zero unless stated:
- unowned runners, or runners silent for 30 days; privileged or shell runners taking unprotected jobs;
- production credentials (variables, runners) reachable from unprotected refs;
- jobs other than the deploy job declaring
id_tokensagainst the production environment; - unprotected production environments, production jobs without a resource group, unpinned components;
- days since the last restore test (healthy: ≤ 90) and minor releases behind (healthy: ≤ 2[3]).
Platform health and recovery (Self-Managed). An outage stops every pipeline. Linux-package installations bundle Prometheus with
host, PostgreSQL and Redis exporters “on by default”[85]; alert on API latency, Sidekiq queues, Gitaly and PostgreSQL
saturation and storage. Set recovery objectives, then choose gitlab-backup with separately stored secrets, per-component procedures
for large instances[38], or Geo (Premium, Ultimate), “a warm-standby secondary site in a different region” that still needs
backups because it replicates corruption[86]. Only a timed restore test proves it.
When things go wrong
Link to section “When things go wrong”Common failures and troubleshooting
Link to section “Common failures and troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
pending, “no matching runners” |
Tag mismatch or protected runners only[4] | Fix tags; default:tags in components |
pending with matching runners |
concurrent, limit, max_instances or quota reached[59] |
Raise capacity or cut demand |
| Two pipelines per push | No workflow:rules guard[6] |
Playbook 1 |
| No scans in MR pipelines | Templates run “for branch pipelines only” | AST_ENABLE_MR_PIPELINES[56] |
410 Gone - runner registration disallowed |
Legacy registration token | Register with the runner’s own token[17] |
| “Outdated deployment job” | A newer deployment already ran[10] | New pipeline for the earlier commit, unless rollback retries are allowed |
Flux stuck, SourceVerified False |
production on an unsigned digest |
Compare flux get sources oci with MANIFEST_DIGEST; re-run the deploy job; find who moved the tag[45] |
| Merge train keeps restarting | A flaky job ejects its MR[27] | Quarantine the test (TestOps) |
Runbook: jobs pending for everyone
Link to section “Runbook: jobs pending for everyone”Detection: p95 queue time above 10 minutes for 15 minutes, or a flood of reports.
Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 130, "nodeSpacing": 16, "rankSpacing": 28, "padding": 6}}}%%
flowchart TB
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
A["Alert: p95 queue time high"]:::risk
B{{"Everyone affected?"}}:::decision
D{{"Managers online?"}}:::decision
C["Check the project's tags, protection, runner enablement"]:::core
E["Restart or fail over managers; check network, TLS"]:::core
F{{"Fleet full?"}}:::decision
G["Raise concurrent, limit, max instances or quota"]:::core
J{{"API errors?"}}:::decision
K["Check the control plane; tune runner request concurrency"]:::core
A --> B
B -->|"yes"| D
B -->|"no"| C
D -->|"yes"| F
D -->|"no"| E
F -->|"no"| J
F -->|"yes"| G
J -->|"yes"| K
J -->|"no"| C
Text equivalent: If one project is affected, check its tags, protection and runner enablement. If everyone is, check that managers are online (else restart or fail over; check network and TLS), then whether the fleet is full (raise limits or quota), then API errors pointing at the control plane; otherwise return to per-project checks.
- Sample pending jobs for tags, projects and refs.
- Managers alive? Last-contact times;
journalctl -u gitlab-runner --since "30 min ago"for connection or TLS errors. - Full?
sum(gitlab_runner_jobs) / sum(gitlab_runner_concurrent)near 1[59][57]. - Autoscaler? Machine states and creation time against
max_instancesand the cloud quota. - Control plane? Rising error and request-concurrency-exceeded counters point at the API or the default
request_concurrency = 1[5].
Metric names for steps 3-5 (GitLab Runner /metrics)
gitlab_runner_jobs, gitlab_runner_concurrent, gitlab_runner_limit
gitlab_runner_autoscaling_machine_states
gitlab_runner_autoscaling_machine_creation_duration_seconds
gitlab_runner_errors_total
gitlab_runner_request_concurrency_exceeded_total
- Mitigate (pause schedules, move a team by tag, raise
max_instances), then record the timeline and the alert that would have caught it.
Runbook: a stuck upgrade hop
Link to section “Runbook: a stuck upgrade hop”Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 210, "nodeSpacing": 30, "rankSpacing": 22, "padding": 8}}}%%
flowchart TB
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
V0["Current: 18.3"]:::risk
V1["18.5 stop"]:::core
V2["18.8 stop"]:::core
V3["18.11 stop"]:::core
V4["19.2 stop"]:::core
V5["Target: 19.5"]:::core
G["Gate before each hop: backup with secrets, migrations finished, checks pass"]:::decision
V0 --> V1 --> V2 --> V3 --> V4 --> V5
V1 -.- G
V2 -.- G
V3 -.- G
V4 -.- G
Text equivalent: An illustrative path from 18.3 through the required stops 18.5, 18.8, 18.11 and 19.2 to 19.5; before every hop, a backup with secrets, finished background migrations and passing health checks. Confirm the path with GitLab’s Upgrade Path tool.
Symptom. The upgrade stops because a background migration is still “active”[12]. Diagnosis. Each gate runs on the version
you are leaving, so on this path none can use wait, which arrives with 19.5. Example status: bash -n syntax-checked; not
executed. Commands and SQL quoted from GitLab’s background-migrations page[12].
# 1. What is still queued, running or failed?
sudo gitlab-rake gitlab:background_migrations:list # 18.9 and later
sudo gitlab-rake gitlab:background_migrations:status # 18.8 and earlier
# Any version: zero rows means all batched background migrations are complete.
sudo gitlab-psql -c "SELECT job_class_name, table_name, column_name, job_arguments FROM batched_background_migrations WHERE status NOT IN(3, 6);"
# 2. 19.5 and later only: block until nothing is paused, active or finalizing; non-zero exit if one failed.
# The task has no internal timeout, so bound it.
sudo timeout 6h gitlab-rake gitlab:background_migrations:wait
# 3. Health checks before and after each hop.
sudo gitlab-rake gitlab:check
sudo gitlab-rake gitlab:doctor:secrets
Resolve a failed migration first (GitLab documents a manual finalize)[12]. Rollback: restore the pre-hop backup to the
same version and edition, with its secrets[38]. Prevention: gate hops in automation; rehearse on a clone[65].
Anti-patterns and myths
Link to section “Anti-patterns and myths”| Anti-pattern or myth | Why it fails | Instead |
|---|---|---|
| One shared pool with privileged Docker-in-Docker | Any Developer can reach the host[8] | Rootless builds; privileged only on protected, ephemeral runners |
retry: 2 everywhere |
Hides flaky tests, burns minutes | Retry infrastructure failures only |
kubectl apply to production with a stored kubeconfig |
Long-lived credential, no drift correction[14] | Flux; CI promotes a signed artifact |
| Upgrading straight to the newest version after a pause | Skipped stops and unfinished migrations fail[11] | Hop by hop, every one to two months |
| “The Kubernetes executor is always cheaper” | Pod requests and noisy neighbours add cost and risk | Dedicated CI node pools |
Deciding and adopting
Link to section “Deciding and adopting”When to use and when not to use
Link to section “When to use and when not to use”Invest when GitLab serves more than a couple of teams, you run your own runners or instance, production needs approval evidence, CI cost is material, or developers routinely wait. Keep it light for one small team on GitLab.com with hosted runners: defaults, Playbook 1 and a monthly usage check. Do not build a GitLab-specific practice when GitLab only mirrors code built elsewhere, or several SCM platforms must stay portable: invest in DevOps, GitOps and PlatformOps and keep the GitLab layer thin. Someone must own fleet security, release notes, each May’s major release and, on Self-Managed, restore tests; if nobody will, buy the more managed option.
Decision framework
Link to section “Decision framework”Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 130, "nodeSpacing": 16, "rankSpacing": 28, "padding": 6}}}%%
flowchart TB
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
Q1{{"Must you run GitLab yourself, for example air-gapped?"}}:::decision
Q2{{"Need single-tenant isolation without operating it?"}}:::decision
COM["GitLab.com"]:::core
DE["GitLab Dedicated"]:::core
Q3{{"Jobs need a private network, special hardware or steady heavy load?"}}:::decision
HR["Hosted runners: budget compute minutes"]:::neutral
SM["Self-Managed: size by RPS; standalone to about 2,000 users, HA from 3,000"]:::core
Q4{{"Well-run Kubernetes platform already available?"}}:::decision
K8["Kubernetes executor on dedicated CI node pools"]:::core
DA["Docker Autoscaler on ephemeral VMs"]:::core
Q5{{"Any jobs privileged, deploying, or using production identity?"}}:::decision
PF["Add a separate protected fleet"]:::core
OK["Single unprivileged fleet is enough"]:::neutral
Q1 -->|"no"| Q2
Q1 -->|"yes"| SM
Q2 -->|"no"| COM
Q2 -->|"yes"| DE
COM --> Q3
DE --> Q3
Q3 -->|"no"| HR
Q3 -->|"yes"| Q4
SM -->|"no hosted runners"| Q4
Q4 -->|"yes"| K8
Q4 -->|"no"| DA
HR --> Q5
K8 --> Q5
DA --> Q5
Q5 -->|"yes"| PF
Q5 -->|"no"| OK
Text equivalent: Must you run GitLab yourself? Then Self-Managed with your own runners (hosted runners exist only on GitLab.com and Dedicated). Otherwise Dedicated for single-tenant isolation, else GitLab.com with hosted runners, unless jobs need a private network, special hardware or heavy steady load. Own runners: Kubernetes executor on a well-run platform, else Docker Autoscaler. Privileged or production-identity jobs get a separate protected fleet.
The tree encodes GitLab’s general sizing advice[40] and runner security guidance[8]; the rest is this Atlas’s judgement. Worked comparison: 80 developers on GitLab.com, private-network deployments, a platform team of two; scores are assumptions, except hosted-runner isolation: each job “runs in a newly provisioned VM, which is dedicated to the specific job”[36].
| Criterion (weight) | Hosted runners | Docker Autoscaler | Kubernetes executor |
|---|---|---|---|
| Isolation per job (0.30) | 5 | 5 | 3 |
| Operating effort, higher = less (0.25) | 5 | 2 | 3 |
| Cost at this volume (0.20) | 2 | 4 | 4 |
| Private network access (0.15) | 1 | 5 | 5 |
| Start-up latency (0.10) | 4 | 3 | 4 |
| Weighted total | 3.70 | 3.85 | 3.60 |
With no private-network deployments (network weight 0, effort 0.40) the totals become 4.30, 3.40 and 3.30: the answer flips, and the close base case could flip on your cost estimate alone.
Maturity model
Link to section “Maturity model”This Atlas’s model, not an official one. Find the level whose behaviour matches yours; move up when its exit criteria have held for a quarter.
Diagram source (Mermaid)
%%{init: {"flowchart": {"wrappingWidth": 300, "rankSpacing": 18, "padding": 8}}}%%
flowchart TB
classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
L1["1 Ad hoc: copied pipelines, runners on any host, secrets in variables"]:::risk
E1["Exit: runners owned, no duplicate pipelines"]:::decision
L2["2 Repeatable: shared skeleton, protected default branches, runner inventory"]:::neutral
E2["Exit: no long-lived cloud keys, no privileged runners on unprotected jobs"]:::decision
L3["3 Managed: pinned components, fleets by trust, OIDC, protected environments and promotion tag"]:::core
E3["Exit: dashboards live, SLOs defined, inside the security window"]:::decision
L4["4 Measured: weekly review of feedback, queue, success and cost; upgrade calendar"]:::core
E4["Exit: SLOs met for a quarter, restore tested within 90 days"]:::decision
L5["5 Optimising: before/after data for every change, policies as code"]:::core
L1 --> E1 --> L2 --> E2 --> L3 --> E3 --> L4 --> E4 --> L5
Text equivalent: Five levels, each followed by an exit gate. 1 Ad hoc (copied pipelines, runners on any host, secrets in variables): exit when runners are owned and no pipeline runs twice. 2 Repeatable (shared skeleton, protected default branches, runner inventory): exit when no long-lived cloud keys remain and no privileged runner takes unprotected jobs. 3 Managed (pinned components, fleets by trust, OIDC, protected environments and promotion tag): exit when dashboards are live, SLOs defined and the instance inside the security window. 4 Measured (weekly review of feedback, queue, success and cost; upgrade calendar): exit when SLOs held for a quarter and a restore was tested within 90 days. 5 Optimising: before/after data for every change, policies as code.
Adoption roadmap: 30/60/90 days
Link to section “Adoption roadmap: 30/60/90 days”For level 1–2 with a small platform team.
| Window | Work | Milestone |
|---|---|---|
| Days 1–30: see and stabilise | Runner inventory and owners; isolate privileged or shell runners serving untrusted jobs; Playbook 1 in the ten busiest projects; Lab 3 baseline | Baselines and inventory done |
| Days 31–60: make the safe path easy | 3–5 pinned components; a protected fleet; OIDC for one cloud account (security signs off the trust policy); protected environments and promotion tag | Half the projects on the skeleton; first cloud keys gone |
| Days 61–90: run it as a service | Queue-time dashboards and alerts; weekly review; upgrade calendar; registry clean-up | Queue-time SLO met for two weeks |
Toolkit
Link to section “Toolkit”Checklists
Link to section “Checklists”New project
- Baseline component pinned;
workflow:rulesprevent duplicates; testsinterruptible; scans in MR pipelines - Default branch protected; MR pipelines must pass; no secrets in
.gitlab-ci.yml; Lint dry run passes on two refs
New runner fleet
- Owner and trust level; tags and access level set at creation; token in a secret manager
- One clean environment per job;
privileged = falseunless protected and ephemeral; private metrics endpoint - Capacity sized with Little’s law; time-boxed idle policy; runner version matches GitLab’s
Production deployment readiness
-
deployment_tier: production; protected environment with approvers; triggerer cannot approve;resource_groupset - “Prevent outdated deployment jobs” on; rollback retries decided; rollback rehearsed
- Tag rule tested (a Developer cannot move the tag; a Maintainer runs the deploy job); Flux refuses an unsigned artifact
- Signing role bound to the production-environment subject; only the deploy job declares
id_tokens; averifyjob onmainrefused
Upgrade hop (Self-Managed)
- Path from the Upgrade Path tool; rehearsed and timed on a clone; Playbook 5’s before and after steps done
Templates
Link to section “Templates”Runner request form
Runner request
- Requesting team / owner:
- Purpose (build, test, deploy, special hardware):
- Trust level: [ ] general [ ] protected refs only [ ] public/fork contributions
- Scope: [ ] project [ ] group [ ] instance (admin approval)
- Executor and image constraints:
- Needs privileged mode? If yes, why, and why rootless builds are not enough:
- Network access needed (registries, private endpoints, cloud APIs):
- Expected peak jobs per hour and average job duration:
- Credentials: OIDC audience and trust conditions (no long-lived keys):
- Approval: runner operator ____ security (if protected/privileged) ____
Decision record for a pipeline or fleet change
ADR-NNN: <decision title>
Status: proposed | accepted | superseded by ADR-MMM
Date: YYYY-MM-DD
Context: what problem, which data (baseline metrics), which GitLab version and tier
Options considered: at least two, including "do nothing"
Decision: the chosen option and the conditions under which it holds
Consequences: cost, security, operating effort, lock-in, migration later
Verification: which metric, which threshold, reviewed on which date
Rollback: how to return to the previous state
Monthly scorecard: the charter’s goals plus a retry rate ≤ 3 % and a falling cost per successful pipeline, each with an owner and last month’s value (example targets, not benchmarks).
Deep comparisons
Link to section “Deep comparisons”| Reuse mechanism, weakest to strongest | Versioning and inputs | Use when |
|---|---|---|
| Copy-paste YAML | No | Never for shared logic |
include: of files |
By ref | Migration |
| CI/CD components | Versions, typed inputs[28] | Shared logic |
| CI file in another project | By ref | Pipelines teams must not edit[35] |
| Pipeline execution policies (Ultimate) | Policy project | Enforced jobs[87] |
Is “GitLabOps” a certification or job title? No. Look for the skills or GitLab’s own credentials, such as “GitLab Certified CI/CD Associate”[88]; GitLab University’s catalogue could not be read automatically, so check current names there.
Do I need Premium or Ultimate? Not to start: merge-request pipelines, components, runners, environments and SAST are Free[9][28][69]. Paid tiers add merge trains, protected environments, policies, push protection and DORA dashboards[27][32][87][61][39].
Hosted runners or our own? Hosted runners need no fleet operations and isolate each job in a fresh VM, but bring compute-minute quotas and less network control[36][82]; see the decision framework.
How often must a self-managed instance be upgraded? At least every two months, to the newest release (Playbook 5)[3]. Required stops limit how far one hop goes, not how often.
Can we compare GitLab’s DORA dashboard with our own DORA data? Only after aligning definitions: GitLab implements the earlier four metrics with its own data rules[39]; the DORA guide now lists five[66].
Going further
Link to section “Going further”Advanced topics and emerging direction
Link to section “Advanced topics and emerging direction”Worth mastering: merge trains sized to runner capacity[27], dynamic child pipelines[81] and policy projects[87]. Watch: the 20.0 removals in May 2027 (origins); the change that lets non-deploying jobs start without a manual action, behind a feature flag that is off by default in 19.5[54]; and GitLab’s push towards AI agents (“GitLab MCP server generally available” in 19.5)[89], which is vendor direction, not yet evidence.
Learning path
Link to section “Learning path”New to delivery? Start with DevOps; prerequisites are in At a glance.
| Stage | Focus | Practice |
|---|---|---|
| Day 1 | Stages, rules, needs, cache vs artifacts, MR pipelines[7] |
Lab 1 |
| Week 1 | Runners, executors, protection, variables, environments | Lab 2; a skeleton component |
| Month 1 | Autoscaling, OIDC, protected environments, Flux, metrics | Lab 3; the reference implementation in a sandbox |
| Then | Fleet engineering, administration, CI security, GitOps on GitLab | Shadow an upgrade hop; threat-model pipelines with the OWASP list[67] |
Then go deeper on GitOps, DevSecOps, ReleaseOps, TestOps and KubernetesOps.
Wrap-up
Link to section “Wrap-up”Executive recap and cheat sheet
Link to section “Executive recap and cheat sheet”Decide: the offering, runners by trust, signed pull for Kubernetes production, the tier whose controls you need. Measure:
p95 feedback and queue time, success rate, cost per successful pipeline, version lag. Watch: privileged runners on shared
fleets; a tag any Developer can move; a signing identity any main job can request; restores without the secrets file.
Lint dry run POST /api/v4/projects/:id/ci/lint {content, dry_run: true, include_jobs: true, ref}
Create runner POST /api/v4/user/runners runner_type=group_type group_id=… tag_list=… access_level=ref_protected
Register runner gitlab-runner register --non-interactive --url … --token glrt-… --executor docker
Runner metrics listen_address = "<private-ip>:9252" → GET /metrics
Migrations gate sudo gitlab-rake gitlab:background_migrations:wait (19.5+; earlier: :list or :status)
MR scanners variables: AST_ENABLE_MR_PIPELINES: "true"
Promotion guard protected container tag ^production$ + Flux OCIRepository spec.verify (cosign)
Health checks sudo gitlab-rake gitlab:check ; sudo gitlab-rake gitlab:doctor:secrets
Backup sudo gitlab-backup create (+ /etc/gitlab/gitlab-secrets.json and gitlab.rb separately)
No duplicates workflow:rules … if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS → when: never
One deploy at a time resource_group: production
A judgement call: one shared fleet or a separate build fleet?
Link to section “A judgement call: one shared fleet or a separate build fleet?”Situation (illustrative). A 60-developer organisation runs one autoscaled instance-runner fleet; a fifth of jobs build images with
Docker-in-Docker, so the whole fleet is privileged = true. The platform team proposes a protected build fleet with rootless
BuildKit (B); a senior engineer prefers the one privileged fleet (A). Assumptions: any Developer on any served project can start
jobs; contractors contribute; VMs run one job each; a second fleet costs about a day a month.
Reasoning. (1) Worst failure: privileged containers effectively disable container security and make breakout possible[8], so a compromised merge request in any project reaches the VM and its network (cache bucket, cloud metadata). (2) Does ephemerality rescue A? Partly; GitLab pairs ephemeral VMs with dedicated runners restricted to protected branches, and A has only the first half. (3) Cost of B: a second manager and a tag change; rootless builds may fail on some runners, where GitLab suggests Buildah[70]. (4) What would change the answer: if everyone who can trigger a job is equally trusted with production and the fleet reaches nothing sensitive, A’s simplicity wins.
Decision: B. A saves one visible fleet and accepts an invisible cost: every project’s weakest contributor sets the security level of every build. Uncertainty: rootless failure rates here are unknown, so a two-week pilot on the five busiest image-building projects measures them first.
Learning objectives and mastery check
Link to section “Learning objectives and mastery check”Objectives: explain the label and its GitOps boundary; design duplicate-free pipelines; size and segment fleets; close deployment bypasses; diagnose pending jobs and blocked upgrades; judge changes with percentiles.
1. “We do GitLabOps, so we already do GitOps.” What is wrong?
2. Jobs tagged gpu have pended for an hour; the GPU runner is online and idle. Three causes, quickest check first?
3. Peak load is 240 jobs an hour, 5 minutes each, plus about 1 minute of VM overhead. How many slots?
4. You are on 18.6 and must reach 19.5; the first hop fails on a background migration. What now?
5. A contractor’s fork merge request adds a job that prints environment variables. What protects you?
The pipeline runs in the fork with the fork’s variables, so the parent’s secrets are absent; the maintainer must not run it in the parent project without reading the diff[9].
6. A lead says production is safe “because the deploy job needs an approval”. Is it?
Conclusion
Link to section “Conclusion”Adopting GitLab gives you a delivery platform, not a delivery operation. The label has no standing; the craft does. The hard parts are not in the YAML: they are the queue before the runners, the trust boundary around privileged containers, the identity each job carries, the tag production pulls, and the upgrade path. All facts are dated October 2026 (GitLab 19.x); re-check them.
Sources and further reading
Link to section “Sources and further reading”GitLab docs are the live 19.x pages, which carry no publication date. Ledger: research/gitlabops-ledger.md.
Changelog. 2026-10-16: draft (GitLab 19.x). r1: signed, protected promotion; MR scanners; migration gates. r2:
environment-bound signing role; transparency log. r3: deploy-job-only signing identity; Blueprint v2; 17,253 → 8,997 words.
r4 (reviews/gitlabops-score-r3.md): restored the rootless-BuildKit caveat; CI tag pushes judged by the job’s user (19.5 source);
four-loops figure; standalone maturity figure; use-case pitfalls; 8,997 → 8,993 words. r4 follow-up (orchestrator): registration-token
quote attributed to the workflow doc; GitLab.com’s 15-minute rule propagation.
GitLab homepage — GitLab Inc. Checked 2026-10-16. ↩︎
How to Use GitLab for Ops — GitLab Inc. Checked 2026-10-16. ↩︎
Release and maintenance policy — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Configuring runners — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Advanced configuration — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
workflowexamples — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎CI/CD YAML syntax reference — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Security for self-managed runners — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Merge request pipelines — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Deployment safety — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Upgrade paths — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Batched background migrations — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
OpenGitOps principles v1.0.0 — CNCF GitOps Working Group. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Agent for Kubernetes and installation — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Choose a subscription — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
CI/CD variables — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
New runner registration workflow and its 16.7 version — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Agent GitOps (deprecated), 16.2 — GitLab Docs archive, 2023. Checked 2026-10-16. ↩︎
Docker Autoscaler executor — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Docker Machine autoscaling — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Update guidance and content regarding the previously planned removal of the Runner Registration Token in GitLab 18.0 — GitLab issue 525239, 2025. Checked 2026-10-16. ↩︎
CI/CD job token — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Use kaniko (removed) — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
CI Lint API — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Pipeline efficiency — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Merged results pipelines — GitLab Docs. Checked 2026-10-16. ↩︎
Merge trains — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
CI/CD components — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Manage runners — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Environments — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Protected environments and Deployment approvals — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
OIDC authentication using ID tokens — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Architecture overview — GitLab Docs. Checked 2026-10-16. ↩︎
Customize pipeline configuration — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Runners and GitLab-hosted runners — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Job artifacts — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Back up GitLab and overview — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
DORA metrics in GitLab — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Reference architectures — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Users API: create a runner — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Caching in GitLab CI/CD — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Protected container tags — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Reduce container registry storage — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
OCI Repositories — Flux (CNCF). Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
GitOps with Flux and the agent — GitLab Docs. Checked 2026-10-16. ↩︎
Configure OpenID Connect in AWS — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Authenticate with the container registry — GitLab Docs. Checked 2026-10-16. ↩︎
Container registry metadata database — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
container_registry_authentication_service.rb and auth.rb at tag v19.5.0-ee — GitLab source, 2026-10-15. Checked 2026-10-16. ↩︎
Cosign key management, verifying signatures, cosign login — Sigstore. Checked 2026-10-16. ↩︎ ↩︎
Keyless signing with Sigstore — GitLab Docs. Checked 2026-10-16. ↩︎
Projects API — GitLab Docs (
ci_id_token_sub_claim_components). Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎Merge request 161860 (merged 2024-08-12), its feature-flag definition at v19.5.0-ee and issue 437142 — GitLab. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Code Owners — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Security configuration — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Plan and operate a runner fleet — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
GitLab Runner Helm chart, its configuration, Kubernetes executor — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Monitor runner usage — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
GitLab Runner — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Secret push protection — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Pipeline secret detection — GitLab Docs. Checked 2026-10-16. ↩︎
Resource groups — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Using GitLab CI/CD with a Kubernetes cluster — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Before you upgrade — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
DORA metrics guide — DORA, updated 2026-01-05. Checked 2026-10-16. ↩︎ ↩︎
OWASP Top 10 CI/CD Security Risks — OWASP Foundation. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
NIST SP 800-204D — NIST, February 2024. Checked 2026-10-16. ↩︎
Build Docker images with BuildKit — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Use Docker to build Docker images — GitLab Docs. Checked 2026-10-16. ↩︎
CI/CD analytics — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Run GitLab Runner in a container — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Root Cause Analysis — GitLab Docs. Checked 2026-10-16. ↩︎
Unit test reports — GitLab Docs. Checked 2026-10-16. ↩︎
flux push artifact, flux tag artifact, OCI artifacts cheatsheet — Flux (CNCF). Checked 2026-10-16. ↩︎
cosign sign and cosign verify; flag sources of 3.1.3 (sign, rekor) and 2.6.5 (sign, rekor) — Sigstore, released 2026-08-06. Checked 2026-10-16. ↩︎ ↩︎
source-controller v1.9.6
cosign.go— Flux, shipped with Flux 2.9.6 on 2026-10-01. Checked 2026-10-16. ↩︎CI/CD pipelines — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Pipelines API — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Downstream pipelines — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎
Compute minutes — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Hosted runners on Linux — GitLab Docs. Checked 2026-10-16. ↩︎
Little’s law — Wikipedia (J. D. C. Little, 1961). Checked 2026-10-16. ↩︎ ↩︎
Monitoring GitLab with Prometheus — GitLab Docs. Checked 2026-10-16. ↩︎
Security policies — GitLab Docs. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
GitLab Certified CI/CD Associate — GitLab, on Credly. Checked 2026-10-16. ↩︎
GitLab 19 release notes — GitLab Docs. Checked 2026-10-16. ↩︎