CoursesSecure CI/CD with GitLabDeploy gates & approvals

Deploy gates & approvals

Change control without a ticket queue.

Intermediate10 min · lesson 16 of 17

A nightclub door has two kinds of bouncer. One reads your ID, checks the photo, checks the date. The other holds the rope and waves through anyone who looks confident. From across the street they look identical. Only one of them is security. Now look at your own door. The main pipeline has gone green: SAST (Static Application Security Testing, which reads your source code looking for flaws), dependency scanning, container scanning, and signing with cosign (a tool that cryptographically signs container images) all passed. The deploy-prod job sits there with a play button on it. An engineer clicks. The release ships. Stop on that click and ask what it verified. If the honest answer is 'nothing, the person saw green checkmarks and assumed the image was fine,' you have the second bouncer. A real deploy gate refuses to move an artifact toward production until the evidence is present and holds up under checking. Only then does it ask a human to own the timing.

A deploy gate is a deliberate checkpoint standing between a pipeline that passed and a release that ships. GitLab gives you two of them, and they do genuinely different work. The first is a pipeline job that re-checks the evidence itself: is this image signed by us, and did the scan come back clean? If either answer is no, the job fails, and an unsigned or vulnerable image cannot physically reach the deploy step. The second is a protected environment with approval rules, which parks the deployment until named approvers sign off through the API (Application Programming Interface, the machine-to-machine way of talking to GitLab) or the web interface. One gate proves the facts. The other records a person accepting them. Pipelines you would trust with real money run both.

.gitlab-ci.yml
include:
- template: Jobs/Container-Scanning.gitlab-ci.yml
stages: [build, test, scan, verify, deploy]
container_scanning:
stage: scan # run the template's scan job in our scan stage
verify-evidence:
stage: verify
image: registry.acme.internal/ci-security:2.4 # cosign + jq on a shell (alpine) base
needs:
- job: container_scanning
artifacts: true # pull gl-container-scanning-report.json
script:
- |
cosign verify \
--certificate-identity-regexp "^${CI_SERVER_URL}/${CI_PROJECT_PATH}/" \
--certificate-oidc-issuer "${CI_SERVER_URL}" \
"${IMAGE}@${DIGEST}"
- |
CRIT=$(jq '[.vulnerabilities[] | select(.severity=="Critical")] | length' \
gl-container-scanning-report.json)
echo "Critical vulnerabilities: ${CRIT}"
test "${CRIT}" -eq 0 # non-zero exit fails the gate
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-prod:
stage: deploy
image: registry.acme.internal/deploy-tools:1.8
environment:
name: production # protected env + approval rules
url: https://acme.example.com
needs: [verify-evidence] # gate must pass before deploy exists
script:
- ./deploy.sh "${IMAGE}@${DIGEST}"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual

verify-evidence is the gate that enforces. It re-runs cosign verify against the digest, the immutable fingerprint of the image contents, which cannot be quietly repointed at something else the way a tag can. Then it pins --certificate-identity-regexp to this project's own CI identity (CI is continuous integration, the automated system that builds and tests every push). That pin carries more weight than it looks. A passport with a real hologram and a valid expiry date is still the wrong passport if it belongs to somebody else. An image can carry a technically perfect signature produced by a completely different pipeline, and without the pin it walks straight through. 'Is it signed' is not the question. 'Is it signed by us' is. The job then opens gl-container-scanning-report.json, the report the scan stage left behind, and uses jq (a small command-line tool for pulling values out of JSON, the text format the report is written in) to count findings marked Critical. Anything other than zero and test exits non-zero, so the job goes red. Because deploy-prod declares needs: [verify-evidence], a red gate means the deploy job never comes into existence at all. None of this depends on an approver remembering to look.

job log — verify-evidence
$ cosign verify --certificate-identity-regexp "^https://gitlab.acme.internal/acme/payments/" ...
Verification for registry.acme.internal/payments@sha256:9f2a3c8e... --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
$ CRIT=$(jq '[.vulnerabilities[] | select(.severity=="Critical")] | length' gl-container-scanning-report.json)
$ echo "Critical vulnerabilities: ${CRIT}"
Critical vulnerabilities: 0
$ test "${CRIT}" -eq 0
Job succeeded

Read that log for what it actually claims, line by line. The signing certificate chains back to a trusted CA (certificate authority, an organisation whose job is vouching for certificates). An entry for this signature exists in the Rekor transparency log, a public append-only ledger of signatures that nobody can quietly edit later. And the image carries zero Critical vulnerabilities. Swap the digest for a tampered image in the registry and cosign verify exits 1 with 'no matching signatures.' Let one Critical CVE (Common Vulnerabilities and Exposures, the public catalogue of known software flaws) slip in and the test line goes red. The verdict is deterministic. The same evidence produces the same answer on every single run, and there is nobody in the loop who can have a bad morning and skip a step.

The human half: approvals on a protected environment

Evidence settles the questions that have a right answer. Is it signed by us? Are there Critical findings? A machine handles those better than you do. The other question has no computable answer: should we push this out right now, on a Friday afternoon, in the middle of a payments freeze, while the on-call engineer is already fighting something else? That call belongs to people, and GitLab gives it a home in protected environments. A protected environment works like the two-key drawer at a bank. Marking production protected narrows who is even permitted to deploy there. Attaching approval rules on top means a set number of named users or groups must sign off before any deployment to that environment moves an inch. Two separate levers. One decides who may pull the trigger, the other decides who has to say yes. You configure both once, through the API.

shell — configure protected environment
$ curl --request POST \
--header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"name": "production",
"deploy_access_levels": [{ "access_level": 40 }],
"approval_rules": [{ "group_id": 42, "required_approvals": 1 }]
}' \
"https://gitlab.acme.internal/api/v4/projects/812/protected_environments"
{
"name": "production",
"deploy_access_levels": [
{ "access_level": 40, "access_level_description": "Maintainers" }
],
"approval_rules": [
{ "group_id": 42, "required_approvals": 1, "group_inheritance_type": 0 }
]
}

With that rule live, pressing play on the when: manual deploy job releases nothing. It creates a deployment that lands straight in the blocked state, holding for one approval from the security group. That split is the whole design. when: manual governs who is allowed to trigger the job. The approval rule governs who must consent before the trigger means anything. In the gap between the two, the deployment sits on the record: visible, attributed to whoever started it, and completely idle until an approver acts through the deployment approval API.

GitLab API — approve the blocked deployment
# 1. Find the deployment waiting on approval
$ curl --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.acme.internal/api/v4/projects/812/deployments?environment=production&status=blocked"
[
{
"id": 45231,
"iid": 204,
"status": "blocked",
"pending_approval_count": 1,
"ref": "main",
"sha": "b83f9c1a...",
"deployable": { "pipeline": { "id": 90312 } }
}
]
# 2. Approve it, on the record
$ curl --request POST --header "PRIVATE-TOKEN: $TOKEN" \
--data "status=approved" \
--data "comment=cosign identity + zero Critical CVEs verified" \
"https://gitlab.acme.internal/api/v4/projects/812/deployments/45231/approval"
{
"user": { "id": 57, "username": "priya.nair", "name": "Priya Nair" },
"status": "approved",
"created_at": "2025-06-18T14:22:07.512Z",
"comment": "cosign identity + zero Critical CVEs verified"
}

That approval response is the change record an auditor will ask you for, and it printed itself like a receipt. A named user. An explicit approved status. A timestamp down to the millisecond. A free-text comment tying the sign-off to the evidence that was actually checked. All of it bound to one deployment, one commit, one pipeline. No separate ticketing tool to keep in sync. No screenshot of a thumbs-up in a Slack thread. Rejection behaves the same way with status=rejected, and the deployment stays blocked until it expires or a newer pipeline replaces it.

You end up with change management and no second system bolted onto the side of it. Every production deploy already carries who approved it, who triggered it, at which commit, on which pipeline, and why. That is the exact trail a compliance review reconstructs six months later, and the same one you walk backwards at 3 a.m. during a post-mortem. The two controls also hold each other up. The machine gate guarantees the evidence exists in the first place. The human gate records that a named person looked at it and accepted the risk. Wire it once, and the audit trail falls out of shipping as a by-product instead of being assembled by hand every quarter.

Gate on evidence, not on a click

The failure mode this whole design kills is the rubber stamp. An approval step where the approver clicks yes every time, without opening anything, buys you the look of a control and none of the effect. It is the signature box on a form nobody reads. So move the objective checks into a job that cannot be skipped: signature identity, scan status, and, as the supply-chain course covers, SLSA provenance (Supply-chain Levels for Software Artifacts, a signed statement describing how and where a build was produced). The machine then applies the same policy the same way on every run, and the person is left holding the one decision that genuinely needs a person. A gate that verifies is a control. A gate that only prompts is paperwork.

The deploy gate as a decision on evidence
Deploy gate on every main pipeline
verify-evidence runs before deploy-prod exists
unsigned / wrong signer
cosign verify exits 1
pipeline red, deploy-prod never starts
Critical CVE present
jq + test exit non-zero
gate fails, release blocked
evidence passes
manual deploy-prod becomes playable
deployment created, state = blocked
approver consents
approval API -> approved
deployment proceeds to production
The machine decides the first two branches on evidence alone. Only the last one needs a person, and only once the evidence has already held.
A green pipeline is only as honest as the runner that produced it
The gate checks the artifact, but CI is what produces the evidence the gate reads. So the question moves one step backwards: who can run jobs on your CI? If an attacker can land a job on a shared runner, or open a merge request from a fork that executes your pipeline, they may be able to manufacture a green result or sign the image with an identity you never meant to trust. Three things follow from that. Pin the accepted signer to your project's protected-branch CI identity rather than accepting 'some valid signature'. Run production builds only on protected, isolated runners. And never let a fork merge-request pipeline write to the state repository or reach production credentials. Verify who signed, not that something was signed.
Quick check
01Your deploy-prod job is when: manual and targets a protected production environment that requires one approval. A maintainer presses play. What happens next?
Incorrect — No. Triggering the job and satisfying the approval rule are two independent controls, and pressing play cancels neither.
Correct — Yes. when: manual decides who may trigger the job, and the protected-environment approval rule still demands consent before the deployment moves.
Incorrect — No. Manual jobs can target protected environments perfectly well. Protection adds an approval requirement, it does not ban the job.
Incorrect — No. Deploy access lets someone start the deployment. It does not count as an approval, which has to come from the designated approvers.
02verify-evidence runs cosign verify with --certificate-identity-regexp pinned to this project's own CI identity. What does that pin catch that a plain 'is this image signed at all?' check would wave through?
Incorrect — No. cosign already verifies against the immutable digest. The identity pin is about who did the signing, not about tag drift.
Incorrect — No. How the image is fetched is a separate concern. The regexp pins the signer's OIDC (OpenID Connect) identity, not the connection.
Correct — Yes. Pinning the identity regexp turns away images signed by anyone else, so 'signed' never suffices. It has to be 'signed by us'.
Incorrect — No. Critical findings are caught by the separate jq and test check on the scan report, not by the signature-identity check.
03On a main pipeline, verify-evidence runs jq plus test "${CRIT}" -eq 0, counts 2 Critical vulnerabilities, exits non-zero and turns red. deploy-prod declares needs: [verify-evidence]. What becomes of the release?
Correct — With needs: [verify-evidence], a red gate means deploy-prod is never created, and the mechanical check blocks the release without anyone being asked.
Incorrect — No. A manual job still requires its needs to succeed. A failed dependency means there is no button to press.
Incorrect — No. The release stops well before that. The deploy job never runs, so there is nothing sitting there to approve or reject.
Incorrect — No. The scan job only writes the report. verify-evidence is the gate that enforces, and its failure stops the deploy.

An evidence-backed gate settles exactly one question: is this release allowed out of the building? It says nothing about how the release behaves once it is out there. Plenty of builds pass cosign, come back clean from the scanner, collect their approval, and then fall over the moment real customers touch them. That is the next lesson. Canary, blue/green, and a rollback that finishes in seconds turn 'we approved a bad deploy' into a short blip rather than a lost afternoon.

Try this

Work through “Gate on evidence, not on a click” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a green pipeline is only as honest as the runner that produced it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related