Protected branches, tags & environments
Who can change and deploy what.
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.
$ 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.
$ git push --force origin mainEnumerating 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.
$ 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.
$ 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.
deploy-prod:stage: deployenvironment:name: productionurl: https://acme.examplerules:- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'when: manualscript:- ./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.
$ glab ci get -p 90514 --with-job-details# Pipeline:id: 90514status: blockedref: v2.4.0sha: 6f2c1ab# Jobs:build successtest successdeploy-prod manual
$ 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.
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.