Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: protected branches, environments and approvals

By updated

Improve this page

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:

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.

  1. Open the settings

    Settings → Repository → Protected branches.

  2. Protect main

    SettingRecommended valueRationale
    BranchmainProduction branch
    Allowed to mergeMaintainersOnly maintainers merge into main
    Allowed to push and mergeNo oneNobody pushes directly
    Allowed to force pushNoA force push rewrites history
  3. Check the protection

    Terminal window
    # Via the GitLab API
    curl --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:

Terminal window
# Protect all release/* branches
curl --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:

PatternUsage
mainProduction branch
release/*Release branches
hotfix/*Urgent fixes
*-stableLTS 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 group
project_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}'
done

Protected 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.

  1. Open the settings

    Settings → Repository → Protected tags.

  2. Protect the tag pattern

    SettingRecommended value
    Tag patternv*
    Allowed to createMaintainers
  3. 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.

Terminal window
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:

Terminal window
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.

  1. Declare the environment in the pipeline

    .gitlab-ci.yml
    deploy-production:
    stage: deploy
    script:
    - kubectl apply -f manifests/
    environment:
    name: production
    url: https://app.example.com
    rules:
    - if: $CI_COMMIT_BRANCH == "main"
    resource_group: production
  2. Protect the environment

    Settings → CI/CD → Environments → production → Edit → Protected environment.

    SettingRecommended value
    Allowed to deployMaintainers
    Required approvals1 (minimum)
    ApproversSpecific users or groups
  3. Check the behavior

    Push a commit to main. The pipeline must reach the deploy-production job, 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:

Recommended approval policy
# staging: automatic deployment, no approval
# pre-production: 1 approval (tech lead)
# production: 2 approvals (tech lead + SRE)

Configure via the API:

Terminal window
# Update the required approvals
curl --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.

TierTypical environments
productionproduction
stagingstaging, pre-prod
testingtest, qa
developmentdev, review/*
otherEverything else
.gitlab-ci.yml
deploy-staging:
environment:
name: staging
deployment_tier: staging

Deployment 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.

  1. Via the interface

    Settings → CI/CD → Deploy freezes → Add deploy freeze.

    SettingExample
    Freeze start0 18 * * 5 (Friday 6 p.m.)
    Freeze end0 9 * * 1 (Monday 9 a.m.)
    TimezoneEurope/Paris
  2. 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"
  3. 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.

PeriodCron startCron endUsage
Weekend0 18 * * 50 9 * * 1No deployment on the weekend
Night0 20 * * *0 7 * * *No deployment at night
Black Friday0 0 25 11 *0 0 2 12 *E-commerce freeze in late November
End of quarter0 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.

RuleRecommended valueEffect
Reject unsigned commitsEnabledRequires 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)10Prevents 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.

Terminal window
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.

CheckVerification commandExpected
main protectedcurl .../protected_branchespush_access_levels: [{access_level: 0}]
Force push forbiddensameallow_force_push: false
v* tags protectedcurl .../protected_tagscreate_access_levels: [{access_level: 40}]
Successful pipeline required before mergecurl .../projects/$PROJECT_IDonly_allow_merge_if_pipeline_succeeds: true
Resolved threads requiredsameonly_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 activecurl .../freeze_periodsFriday-Monday period
Push rules active (Premium)curl .../push_ruleRules 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.

SymptomLikely causeFix
Developer can push to mainmain not protectedSettings → Repository → Protected branches → add main
Pipeline deploys without approvalEnvironment 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 injectedPipeline on an unprotected branch/tagCheck that the branch or tag is in the list of protected items
Tag v1.0.0 created by a DeveloperTags not protectedSettings → Repository → Protected tags → add v*
Deploy during a freezeJob without a $CI_DEPLOY_FREEZE ruleAdd rules: - if: $CI_DEPLOY_FREEZE / when: never to the job
MR impossible to merge, no pipeline listedPipelines must succeed ticked on a project whose MRs trigger no pipelineMake a merge request pipeline exist (rules or workflow), or untick the setting
MR blocked while the pipeline went skippedA skipped pipeline does not count as successfulTick Allow merge after skipped pipelines, or fix the rules that skip the pipeline
403 Forbidden on the protected_branches APIToken without the api scope, or account without the Maintainer or Owner roleRecreate 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 configuredConfigure 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:

  1. Protected branches: the base lock. Nobody pushes directly to main, only Maintainers merge. Without it, everything else can be bypassed.
  2. Protected tags: release control. If your release pipelines trigger on a tag, protect the v* pattern so that only Maintainers can tag.
  3. Merge checks: the conditions. Pipelines must succeed and All threads must be resolved refuse 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.
  4. 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".
  5. Deployment freezes: time-based control. Sensitive periods (weekend, commercial freeze, maintenance) are protected by configuration, not by an oral convention.
  6. Push rules (Premium): the earliest control. Signed commits, naming conventions, file size, these checks run server-side at git push time 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.

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

10 questions
5 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn