Deploying to production must be a deliberate, validated and traceable act, not a side effect of a git push. GitLab offers six control mechanisms: protected branches, protected tags, merge checks, protected environments, deployment freezes and push rules. This guide shows you how to configure each layer so that only the right people can deploy the right code at the right time.
What you will learn
By the end of this guide, you will know how to:
- Protect a branch to control who can push and merge
- Protect tags to secure releases
- Require a successful pipeline and resolved threads before any merge
- Create a protected environment with mandatory approval (Premium)
- Configure a deployment freeze to block planned deployments
- Apply push rules to enforce conventions (Premium)
Prerequisites
This lesson assumes that you can write a pipeline whose jobs are triggered by
rules:, that you know what a protected CI/CD variable is (injected
only on a protected branch or tag), and that you have already put the
environment: key on a deployment job. The GitLab edition decides what
you can follow: protected branches, protected tags and merge checks
exist in every edition, CE included; approval exists in Free but locks
nothing there; Code Owners, protected environments and push rules
require Premium or Ultimate.
On the rights side, you need the Maintainer or Owner role on the
project, and a token with the api scope for the API calls below. On the
tooling side, you need
curl and jq to query the API, and DSOXLab for the final lab, which
requires no Premium licence. Upstream lessons:
- Managing secrets: protected and masked variables, which branch protection conditions
- CI/CD variables: define a variable and read it in a job
- Rules conditions: decide when a deployment job starts
- Environments: declare the environment you then protect
- CI/CD workflows: make a pipeline exist for every merge request
In which context?
Branch and environment protection mechanisms are needed as soon as:
- Your pipeline deploys automatically to an environment (staging, production)
- Several developers contribute to the same project and MRs are not systematically reviewed
- You must prove to an auditor that production deployment follows a validation process
- Deployment freeze periods (release freeze, vacations, commercial events) must be respected
- You publish releases through tags and must prevent anyone from creating them
Protected branches
Protecting a branch separates two rights that GitLab grants together by default: the right to push and the right to merge. The target configuration denies push to everyone, Owners included, and reserves merge for Maintainers; every change then has to go through a merge request. The protection also acts on protected CI variables, which are injected only into pipelines running on a protected branch or tag.
The default problem
On a GitLab project, a Maintainer can push directly to main without going through an MR. A Developer can merge an MR without approval. There is no barrier between the code written and the code deployed.
Configure a protected branch
The protection is set at the project level and takes effect immediately, with nothing to restart. Forbidding force push is the most important setting: without it, a rewritten history makes already reviewed commits disappear.
-
Open the settings
Settings → Repository → Protected branches.
-
Protect
mainSetting Recommended value Rationale Branch mainProduction branch Allowed to merge Maintainers Only maintainers merge into main Allowed to push and merge No one Nobody pushes directly Allowed to force push No A force push rewrites history -
Check the protection
Terminal window # Via the GitLab APIcurl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/projects/$PROJECT_ID/protected_branches"Expected result:
[{"name": "main","push_access_levels": [{"access_level": 0}],"merge_access_levels": [{"access_level": 40}],"allow_force_push": false}]access_level: 0= No one,40= Maintainers.
Protect by pattern
You can protect several branches with a wildcard:
# Protect all release/* branchescurl --request POST \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/protected_branches" \ --data "name=release/*&push_access_level=0&merge_access_level=40"Common patterns:
| Pattern | Usage |
|---|---|
main | Production branch |
release/* | Release branches |
hotfix/* | Urgent fixes |
*-stable | LTS branches |
Via the API: automate the protection
To apply the same policy to all the projects of a group:
#!/usr/bin/env bash# protect-branches.sh: apply the branch policy to a group
GROUP_ID="${1:?Usage: $0 <group_id>}"
# Get all the projects of the groupproject_ids=$(curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/groups/$GROUP_ID/projects?per_page=100" \ | jq -r '.[].id')
for pid in $project_ids; do echo "Protecting main for project $pid" curl -s --request POST \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$pid/protected_branches" \ --data "name=main&push_access_level=0&merge_access_level=40&allow_force_push=false" \ | jq '{name, push_access_levels, merge_access_levels}'doneProtected tags
Tags are protected separately from branches, with a single right to grant: the right to create a tag matching the pattern. A protected tag also becomes immutable against deletion by unauthorized profiles, which prevents a release from being removed and then republished on another commit with the same version number.
Why protect tags
A tag often triggers a release pipeline. If any Developer can create a v2.0.0 tag, they can trigger an unauthorized build and deployment.
Configure protected tags
The v* pattern covers the whole vX.Y.Z convention in a single rule and applies to future tags, not only to those that already exist.
-
Open the settings
Settings → Repository → Protected tags.
-
Protect the tag pattern
Setting Recommended value Tag pattern v*Allowed to create Maintainers -
Check via the API
Terminal window curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/projects/$PROJECT_ID/protected_tags"Expected result:
[{"name": "v*","create_access_levels": [{"access_level": 40}]}]
How do you require a successful pipeline before a merge?
Two checkboxes in Settings > Merge requests, under Merge checks, refuse the merge while the work is not in order: Pipelines must succeed and All threads must be resolved. These merge checks exist in every edition, GitLab CE included, and they fill the gap left by protected branches: a protected branch says who may merge, a merge check says on what conditions.
The first one is stricter than it looks. The official documentation states that it checks "the existence of a successful pipeline, not the absence of failed pipelines": a merge request with no pipeline at all cannot merge, and a skipped pipeline blocks the merge too, unless you tick Allow merge after skipped pipelines. GitLab spells out the matching prerequisite: your configuration must run a pipeline for every merge request, otherwise you have just frozen the branch.
The second one is about review. While a reviewer's remark stays open, the Open threads counter of the merge request turns orange and the merge is refused. That is the difference between "a reviewer commented" and "the remark was dealt with".
Check both settings through the API
Both checkboxes are project fields, not pipeline settings: they are read and written on the project object itself, which is what lets you audit dozens of repositories without opening a single interface.
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID" \ | jq '{only_allow_merge_if_pipeline_succeeds, only_allow_merge_if_all_discussions_are_resolved, allow_merge_on_skipped_pipeline}'Expected result:
{ "only_allow_merge_if_pipeline_succeeds": true, "only_allow_merge_if_all_discussions_are_resolved": true, "allow_merge_on_skipped_pipeline": false}The same fields are written with a PUT on the project object:
curl --request PUT \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID" \ --data "only_allow_merge_if_pipeline_succeeds=true" \ --data "only_allow_merge_if_all_discussions_are_resolved=true"What GitLab Free cannot require: approval
An approval is not a merge check, and that is the point which causes the most misunderstandings. In GitLab Free, the official documentation is blunt: any user with at least the Developer role can approve a merge request, but "these approvals are optional and don't prevent merging without approval". The button is there, the lock is not.
Premium and Ultimate add what is missing: approval rules that impose a number and a type of approvers, and a CODEOWNERS file that names the owners of a path so that their approval becomes mandatory on merge requests touching it. The documentation puts Code Owners in Premium and Ultimate: on a CE instance, a CODEOWNERS file therefore has no effect on merging.
In CE, the approval gate is rebuilt from what this guide has already set up: push forbidden on main, merge reserved for Maintainers, a successful pipeline and resolved threads required. Nobody merges without a Maintainer deciding so, but GitLab does not record who approved what, and that is exactly what the paid edition sells.
Protected environments
Environment protection moves the control from the repository to the deployment target: whatever the originating branch, it is access to production that is restricted. It complements protected branches without replacing them, and it is the only one of the six mechanisms that introduces a human validation in the middle of a running pipeline.
The mechanism
A protected environment requires an explicit approval before the deployment runs. The pipeline reaches the deployment job, then pauses until an approver validates.
Configure a protected environment
The environment must exist before it can be protected, and GitLab only creates it the first time a job carrying an environment: key runs. The configuration therefore happens in two steps: declare the environment in the pipeline, then protect it in the interface.
-
Declare the environment in the pipeline
.gitlab-ci.yml deploy-production:stage: deployscript:- kubectl apply -f manifests/environment:name: productionurl: https://app.example.comrules:- if: $CI_COMMIT_BRANCH == "main"resource_group: production -
Protect the environment
Settings → CI/CD → Environments → production → Edit → Protected environment.
Setting Recommended value Allowed to deploy Maintainers Required approvals 1 (minimum) Approvers Specific users or groups -
Check the behavior
Push a commit to
main. The pipeline must reach thedeploy-productionjob, display the blocked status and wait for approval in the interface.
Multi-level approval
For critical environments, you can require several approvers and separate the roles:
# staging: automatic deployment, no approval# pre-production: 1 approval (tech lead)# production: 2 approvals (tech lead + SRE)Configure via the API:
# Update the required approvalscurl --request PUT \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/protected_environments/production" \ --data "required_approval_count=2"Environment tier
GitLab assigns a deployment tier to each environment: production, staging, testing, development or other. Group-level protected environments rely on it, so that two projects can name their production environment differently and still be protected the same way. Without a declaration, GitLab guesses the tier from the environment name (a name containing prod or live is production, stag or pre is staging, test or qa is testing, dev or review is development; anything else is other). To avoid a wrong guess, declare the tier with the deployment_tier keyword, as below. The tier cannot be set from the interface.
| Tier | Typical environments |
|---|---|
| production | production |
| staging | staging, pre-prod |
| testing | test, qa |
| development | dev, review/* |
| other | Everything else |
deploy-staging: environment: name: staging deployment_tier: stagingDeployment freezes
A deployment freeze adds a time-based constraint to the previous layers: the periods are declared as cron expressions with an explicit time zone, which makes them auditable and replaces the oral convention "we do not deploy on Fridays". Beware of a subtlety: during the window, GitLab exposes the $CI_DEPLOY_FREEZE variable, which your deployment jobs must test in order not to run.
The mechanism
A deployment freeze declares a period during which no deployment must go out. The mechanism is declarative, not coercive: during the window, GitLab creates the $CI_DEPLOY_FREEZE variable in the job environment, and nothing else. Pipelines keep running, and a deployment job that does not test this variable goes out like any other day. The actual blocking comes from the rules: you write, never from the freeze period alone. The GitLab documentation is explicit on this point: "If the job that's executing is in a freeze period, GitLab CI/CD creates an environment variable named $CI_DEPLOY_FREEZE".
Configure a freeze
A freeze period is described by two cron expressions, start and end, plus a time zone: without it, GitLab interprets the hours in UTC and the freeze shifts by one or two hours depending on the season.
-
Via the interface
Settings → CI/CD → Deploy freezes → Add deploy freeze.
Setting Example Freeze start 0 18 * * 5(Friday 6 p.m.)Freeze end 0 9 * * 1(Monday 9 a.m.)Timezone Europe/Paris -
Via the API
Terminal window curl --request POST \--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/projects/$PROJECT_ID/freeze_periods" \--data "freeze_start=0 18 * * 5&freeze_end=0 9 * * 1&cron_timezone=Europe/Paris" -
Check the active periods
Terminal window curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/projects/$PROJECT_ID/freeze_periods"
Common freeze patterns
These four patterns cover the most frequent cases. Nothing prevents declaring several on the same project: a weekly freeze and a one-off commercial freeze add up without getting in each other's way.
| Period | Cron start | Cron end | Usage |
|---|---|---|---|
| Weekend | 0 18 * * 5 | 0 9 * * 1 | No deployment on the weekend |
| Night | 0 20 * * * | 0 7 * * * | No deployment at night |
| Black Friday | 0 0 25 11 * | 0 0 2 12 * | E-commerce freeze in late November |
| End of quarter | 0 0 28 3,6,9,12 * | 0 0 2 4,7,10,1 * | Accounting freeze |
Push rules
Push rules run on the GitLab server side, before commits are recorded: a rejected push leaves no trace in the repository and triggers no pipeline. They apply to all the branches of the project, protected or not, which makes them the natural complement of the previous sections. They are a Premium and Ultimate feature.
Complement branch protection
Push rules add checks at push time, before the pipeline is triggered:
Settings → Repository → Push rules.
| Rule | Recommended value | Effect |
|---|---|---|
| Reject unsigned commits | Enabled | Requires GPG-signed commits |
| Commit message regex | ^(feat|fix|docs|chore|refactor|test)(\(.+\))?: .+ | Enforces Conventional Commits |
| Branch name regex | ^(main|release\/.*|feature\/.*|hotfix\/.*)$ | Limits branch names |
| File name regex (deny) | \.(exe|dll|msi|pkg)$ | Blocks binaries |
| Maximum file size (MB) | 10 | Prevents large files |
Via the API
The push_rule endpoint is used with PUT on an existing project. Regular expressions must be URL-encoded: %2B replaces the + of the pattern, otherwise curl sends it as a space and the rule rejects all commit messages.
curl --request PUT \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/push_rule" \ --data "reject_unsigned_commits=true" \ --data "commit_message_regex=^(feat|fix|docs|chore|refactor|test)(\(.%2B\))?: .%2B" \ --data "max_file_size=10"Protection checklist
A protection configured in the interface is always checked through the API: it is the only way to prove the real state, and the only way to audit dozens of projects without opening them one by one. The commands below assume the GITLAB_TOKEN, GITLAB_URL and PROJECT_ID variables are set, with a token carrying the api scope.
| Check | Verification command | Expected |
|---|---|---|
main protected | curl .../protected_branches | push_access_levels: [{access_level: 0}] |
| Force push forbidden | same | allow_force_push: false |
v* tags protected | curl .../protected_tags | create_access_levels: [{access_level: 40}] |
| Successful pipeline required before merge | curl .../projects/$PROJECT_ID | only_allow_merge_if_pipeline_succeeds: true |
| Resolved threads required | same | only_allow_merge_if_all_discussions_are_resolved: true |
| Production env protected (Premium) | UI → Environments | "protected" badge visible |
| Approval required (Premium) | UI → Environment settings | ≥ 1 required approval |
| Weekend freeze active | curl .../freeze_periods | Friday-Monday period |
| Push rules active (Premium) | curl .../push_rule | Rules configured |
Troubleshooting
The most frequent cause of the incidents listed here is a mismatch between what is protected and what is actually used: a protected master branch while the project pushes to main, or an environment named prod in the pipeline and production in the settings. Always compare the exact value, matching is case-sensitive.
| Symptom | Likely cause | Fix |
|---|---|---|
Developer can push to main | main not protected | Settings → Repository → Protected branches → add main |
| Pipeline deploys without approval | Environment not protected (or CE edition, where the feature does not exist) | Settings → CI/CD → Environments → Edit → check Protected; in CE, use when: manual on a protected branch |
| Protected variable not injected | Pipeline on an unprotected branch/tag | Check that the branch or tag is in the list of protected items |
Tag v1.0.0 created by a Developer | Tags not protected | Settings → Repository → Protected tags → add v* |
| Deploy during a freeze | Job without a $CI_DEPLOY_FREEZE rule | Add rules: - if: $CI_DEPLOY_FREEZE / when: never to the job |
| MR impossible to merge, no pipeline listed | Pipelines must succeed ticked on a project whose MRs trigger no pipeline | Make a merge request pipeline exist (rules or workflow), or untick the setting |
MR blocked while the pipeline went skipped | A skipped pipeline does not count as successful | Tick Allow merge after skipped pipelines, or fix the rules that skip the pipeline |
403 Forbidden on the protected_branches API | Token without the api scope, or account without the Maintainer or Owner role | Recreate the token with the api scope and check that the account is a Maintainer or Owner of the project |
| Push rejected: "commit not signed" | reject_unsigned_commits enabled without GPG configured | Configure GPG on the developer's machine or temporarily disable the rule |
Hands-on lab
The lab starts from a project whose pipeline is green and which carries no lock at all: anyone can push to main, create a v1.0.0 tag that triggers a release, and merge a merge request whose pipeline is red. You set the four locks of this guide that exist in CE: protect main by forbidding push and force push while keeping merging possible, protect a tag pattern covering v1.0.0 for Maintainers only, then require a successful pipeline and resolved discussions before any merge.
Grading reads the settings the forge holds, from its API, and never your .gitlab-ci.yml: how you set the locks, interface or API, is up to you. It also checks a fifth point, which is the real subject: a pipeline must have run on main with both its jobs successful, because locks that forbid everything protect nothing. Two limits to know before you start: the forge does not date a lock, so the lab cannot prove your pipeline ran after you set them, and it measures the setting, not the refusal it causes.
The approval rule and the protected environment are out of scope, since both need a Premium licence: on the lab forge, the protected_environments API answers 404. The lab starts with dsoxlab run gl-24-proteger-les-merges, after dsoxlab provision; installing DSOXLab is described in the DSOXLab documentation.
Key takeaways
Deployment control in GitLab relies on six complementary layers:
- Protected branches: the base lock. Nobody pushes directly to
main, only Maintainers merge. Without it, everything else can be bypassed. - Protected tags: release control. If your release pipelines trigger on a tag, protect the
v*pattern so that only Maintainers can tag. - Merge checks: the conditions.
Pipelines must succeedandAll threads must be resolvedrefuse a merge without a green pipeline or with an untreated remark, in every edition. Mandatory approval, on the other hand, is Premium: in Free an approval prevents nothing. - Protected environments (Premium): human control before production. The pipeline pauses on the deployment job, an approver validates, the job resumes. This is the difference between "it got deployed" and "we decided to deploy".
- Deployment freezes: time-based control. Sensitive periods (weekend, commercial freeze, maintenance) are protected by configuration, not by an oral convention.
- Push rules (Premium): the earliest control. Signed commits, naming conventions, file size, these checks run server-side at
git pushtime and reject the push before any pipeline starts.
Sources
GitLab settings and access levels change from one version to the next: check these official pages if a setting does not match your instance.
- Protected branches, GitLab Docs
- Protected tags, GitLab Docs
- Protected environments, GitLab Docs
- Deployment safety, Deployment freeze, GitLab Docs
- Push rules, GitLab Docs
- Merge checks, require a successful pipeline for merge, GitLab Docs
- Merge request approvals, GitLab Docs
- Code Owners, GitLab Docs
- Roles and permissions, GitLab Docs: the Maintainer or Owner role that protected branches require
- CI/CD variables, Protected variables, GitLab Docs
Test your knowledge
Ten questions on access levels, the real scope of each protection and the behavior of protected variables.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI: secure the supply chain with SBOMs and attestations
A new full quiz with random questions
Work again only on the questions you missed