CoursesSecure CI/CD with GitLabProtected branches, tags & environments

Protected branches, tags & environments

Who can change and deploy what.

Intermediate12 min · lesson 5 of 17

It is 16:50 on a Friday. A developer decides the review queue is too slow, so they run git push --force origin main with a hotfix. That rewrites history and erases two of a teammate's commits. The push goes through. The next pipeline sees a new commit on main, runs the deploy-prod job, and ships the unreviewed change to production in ninety seconds. No approval. No record of who decided to deploy. Every link in that chain (who can push, who can merge, who can deploy) was left wide open. This lesson closes them, and it does it the way a real pipeline does: through the GitLab API (application programming interface, the machine-facing door into GitLab that the web pages themselves knock on), not a screenshot of a settings page.

A protected branch is closer to a deadbolt fitted by the landlord than to a note taped on the door asking people to stay out. The lock lives on GitLab's servers, so it holds whether or not anyone reads the note. In GitLab's own terms, protection is a set of server-side rules attached to refs (ref is Git's word for a named pointer, which covers both branches and tags) and to environments. A protected branch says who may push to it and who may merge into it. The standard setup protects main so that nobody pushes to it directly and only Maintainers can merge, which forces every change through a reviewed merge request. Everything else rests on that one rule. If anyone can push to main, then anyone can rewrite the pipeline definition, the deploy config, and whatever ships to customers. Protection does a second job you will care about later: a CI/CD (continuous integration and continuous delivery) variable marked protected is only handed to jobs running on a protected ref, which is what keeps production credentials away from feature-branch and fork pipelines.

Lock main with a real API call, not a comment

An audit of this material once turned up 'settings' that existed only as comments inside a YAML file (the indented plain-text format .gitlab-ci.yml is written in). Comments configure nothing. GitLab never saw them. Real protection is a call to the REST API, meaning the plain URL-and-verb style of interface GitLab exposes, and the toggles in the web interface call those same endpoints. Access is written as numbers, called access levels: 0 = No one, 30 = Developers + Maintainers, 40 = Maintainers, 60 = Instance admins (self-managed installs only). To protect main so direct pushes are impossible and only Maintainers can merge, you POST push_access_level=0 and merge_access_level=40. The :id in the path is the numeric project ID, shown under the project name and also returned by GET /projects.

terminal
$ curl --request POST --header "PRIVATE-TOKEN: $GL_TOKEN" \
"https://gitlab.com/api/v4/projects/4711/protected_branches?name=main&push_access_level=0&merge_access_level=40&code_owner_approval_required=true"
HTTP/2 201
{
"id": 8102,
"name": "main",
"push_access_levels": [
{ "access_level": 0, "access_level_description": "No one", "user_id": null, "group_id": null }
],
"merge_access_levels": [
{ "access_level": 40, "access_level_description": "Maintainers", "user_id": null, "group_id": null }
],
"allow_force_push": false,
"code_owner_approval_required": true
}

The 201 Created response is your receipt. allow_force_push:false, together with a push level of 'No one', means the Friday force-push has nowhere to land. code_owner_approval_required:true stacks the CODEOWNERS file on top of that (CODEOWNERS is a file in the repo that maps paths to the people responsible for them, and Code Owners is a Premium/Ultimate feature), so a change to a sensitive path such as /.gitlab-ci.yml itself needs sign-off from whoever owns it. Now check the lock from the outside by attempting exactly the push you have forbidden.

terminal
$ git push --force origin main
Enumerating objects: 5, done.
Total 3 (delta 1), reused 0 (delta 0)
remote: GitLab: You are not allowed to force push code to a protected branch on this project.
To gitlab.com:acme/checkout.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to 'gitlab.com:acme/checkout.git'

That pre-receive hook declined line is GitLab refusing the write on its own server, before the ref moves an inch. A pre-receive hook is the check the server runs on incoming pushes before it accepts them. There is nothing to install on the developer's laptop, no honor system, no flag that skips it. The server says no. That gap, between a rule the server enforces and a rule that lives in a wiki page, is the whole difference between protection and good intentions.

Tags need a lock of their own

Branches are half the story. In plenty of shops the tag is the release button: git tag v2.4.0 && git push --tags is what actually sends code to customers. So if anyone can create a v* tag, anyone can cut a release, no matter how tightly main is bolted down. Protected tags restrict who may create tags matching a wildcard pattern. Protect v* at Maintainer level so that shipping becomes a deliberate, privileged act.

terminal
$ curl --request POST --header "PRIVATE-TOKEN: $GL_TOKEN" \
"https://gitlab.com/api/v4/projects/4711/protected_tags?name=v*&create_access_level=40"
HTTP/2 201
{
"name": "v*",
"create_access_levels": [
{ "access_level": 40, "access_level_description": "Maintainers", "user_id": null, "group_id": null }
]
}

create_access_levels now lists Maintainers and nobody else. A Developer who runs git push origin v2.5.0 gets the same pre-receive hook declined rejection. The tag never comes into existence, so the release pipeline it would have started never runs either.

Protected environments: who gets to press Deploy

Branch and tag protection decide what gets into the repository. A protected environment decides what gets out of it, so it is the lock on the loading bay rather than the one on the front door. One note on pricing before the commands: branch and tag protection come with every GitLab plan, while protected environments need Premium or Ultimate. A GitLab environment is a named deployment target such as staging or production. You protect it by declaring which roles may run deployment jobs aimed at it, and optionally by attaching approval_rules, which park a deployment until somebody signs it off. That buys you change control with the audit trail built in (who approved the production deploy, and at what time) without bolting a separate ticketing system onto the side. Protect production so only Maintainers deploy, and make it wait for one Maintainer approval.

terminal
$ curl --request POST --header "PRIVATE-TOKEN: $GL_TOKEN" \
--header "Content-Type: application/json" \
--data '{"name":"production","deploy_access_levels":[{"access_level":40}],"approval_rules":[{"access_level":40,"required_approvals":1}]}' \
"https://gitlab.com/api/v4/projects/4711/protected_environments"
HTTP/2 201
{
"name": "production",
"deploy_access_levels": [
{ "access_level": 40, "access_level_description": "Maintainers", "user_id": null, "group_id": null, "group_inheritance_type": 0 }
],
"approval_rules": [
{ "access_level": 40, "access_level_description": "Maintainers", "required_approvals": 1, "user_id": null, "group_id": null }
]
}

The job that does the deploying has to name that environment, or the rule has nothing to bite on. Pair it with rules:if so the deploy only fires for real semver tags (semver is semantic versioning, the v2.4.0 numbering style), and with when: manual so a person presses Deploy and GitLab writes down who they were. This is current 17.x syntax, rules:if, rather than the deprecated only/except.

.gitlab-ci.yml
deploy-prod:
stage: deploy
environment:
name: production
url: https://acme.example
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
when: manual
script:
- ./deploy.sh "$CI_COMMIT_TAG"

When a v2.4.0 tag lands, the pipeline creates deploy-prod and then refuses to start it. production carries an approval rule asking for one approval, so the deployment sits blocked until an authorized Maintainer approves it. You can watch that gate from a terminal without opening the web interface at all: first the pipeline, then the deployment sitting behind it.

terminal
$ glab ci get -p 90514 --with-job-details
# Pipeline:
id: 90514
status: blocked
ref: v2.4.0
sha: 6f2c1ab
# Jobs:
build success
test success
deploy-prod manual
terminal
$ curl --header "PRIVATE-TOKEN: $GL_TOKEN" \
"https://gitlab.com/api/v4/projects/4711/deployments/514"
HTTP/2 200
{
"id": 514,
"iid": 12,
"ref": "v2.4.0",
"status": "blocked",
"pending_approval_count": 1,
"environment": { "name": "production" },
"approvals": []
}

status:blocked, pending_approval_count:1, and an empty approvals array: that is the gate holding. A Maintainer clears it with POST /projects/4711/deployments/514/approval and status=approved. The deployment's approvals array then records that person and the timestamp, and the job runs. Push, merge, tag, and deploy are now four separate gates, each enforced by the server, each leaving a trace, which is the exact reverse of the wide-open Friday chain this lesson opened with.

Four gates GitLab enforces separately
An actor tries to move code toward production
push, merge, tag, or deploy: GitLab checks a different rule for each
push to main
push_access_level = 0
No one. Direct pushes and force-pushes are rejected by the pre-receive hook.
merge to main
merge_access_level = 40
Maintainers only, through a reviewed merge request, plus Code Owner approval.
create v* tag
create_access_level = 40
Maintainers only. A Developer cannot cut a release.
deploy to prod
deploy_access_level = 40, approvals = 1
A Maintainer must approve the blocked deployment, and the approver is recorded.
Ref protection controls what enters the repo; environment protection controls what leaves it. The four gates stand alone, so securing one does nothing for the other three.
Re-protecting a branch does not update it
POST to /protected_branches for a branch that is already protected and GitLab answers 409 Conflict with {"message":"Protected branch 'main' already exists"}. It does NOT update the levels. So a protection script that gets re-run looks like it passed, because the branch is indeed still protected, while the old and looser access levels quietly stay in place. To tighten an existing rule, PATCH /projects/:id/protected_branches/main with the new fields, or DELETE the rule and POST it again. Afterwards, GET the branch and assert that push_access_level and allow_force_push are what you asked for. Verify the result; never trust the request.
Quick check
01You protect main with merge_access_level=40 (Maintainers) but leave push_access_level at 30. What can a user who holds only the Developer role still do to main?
Incorrect — Push and merge are two independent levels on the same rule. Locking merge leaves push exactly as it was.
Correct — Level 30 means 'Developers + Maintainers', so a Developer's git push origin main is accepted and the merge-request gate is quietly bypassed. Set push_access_level=0.
Incorrect — That would hold only if push_access_level were 0. At 30, direct pushes by Developers are allowed.
Incorrect — Backwards. Force-push is governed by allow_force_push, which defaults to false, while ordinary pushes are exactly what level 30 permits.
02A protection script POSTs to /protected_branches for main again, hoping to tighten the access levels on a branch that is already protected. What does the lesson say really happens?
Incorrect — No. A POST never updates an existing protection, and assuming it does is the exact trap the lesson flags.
Incorrect — No. The POST does not delete and recreate anything; it is refused outright, so no window ever opens.
Incorrect — No. GitLab keeps no duplicate rule; the second POST conflicts rather than layering a stricter one on top.
Correct — Re-protecting is not idempotent, so tighten with PATCH (or DELETE then POST) and GET afterwards to confirm the levels.
03main is locked down (push_access_level=0, merge_access_level=40) and force-push is off. Your production deploy fires on any v* tag, and you never created a protected-tag rule. What is a Developer still able to do?
Correct — Branch protection covers the main ref and nothing else, so without a protected-tag rule any Developer can cut a release by pushing a v* tag.
Incorrect — No. Tag protection is its own control, and a well-locked branch does nothing to limit who creates tags.
Incorrect — No. main's rule applies to the main branch ref, not to the v* tag ref, so it never sees that push.
Incorrect — No. Nothing forces a Maintainer on a deploy job unless a protected environment says so, and that is yet another separate rule.

Server-side protection settles who can push, merge, tag, and deploy. It still trusts the machines that execute the jobs, and a job running on a protected ref can read your protected variables. Put an untrusted pipeline on the same runner as a privileged one and the lock you fitted at the ref level comes undone at the compute level. The next lesson, Runner isolation & protected runners, closes that gap by pinning privileged jobs to runners that only protected refs can reach.

Try this

Run git push --force origin main on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: re-protecting a branch does not update 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