How do protected branches and protected tags work in GitLab, and how do you configure push and merge access controls?
Quick Answer
Protected branches restrict who can push, merge, and force-push to critical branches like main and release/*. Protected tags prevent unauthorized users from creating or updating release tags. Both features use role-based access levels (Maintainers, Developers, No one) to enforce governance at the Git level.
Detailed Answer
Think of protected branches like the vault in a bank. Anyone can fill out a deposit slip at the counter (push to feature branches), but only authorized managers (Maintainers) can open the vault (push to main). Even managers must follow the dual-control policy (merge request with approvals) before moving funds (code) into the vault. The vault has additional safeguards: no one can erase transaction history (force push is disabled), and the safety deposit boxes (protected tags) require manager authorization to open or create.
Protected branches in GitLab restrict three operations on specified branches: who can push directly, who can merge via merge requests, and who can force push. By default, when you create a GitLab project, the main branch is automatically protected with Maintainers allowed to push and merge. You can configure additional protected branches using exact names (release/v2.0) or wildcard patterns (release/*). Each protected branch rule specifies the allowed push access level (No one, Developers + Maintainers, or Maintainers only) and the allowed merge access level (same options). GitLab Premium adds the ability to specify individual users or groups instead of role-based levels. Force push is disabled on protected branches by default, preventing history rewrites that could lose commits or break other developers' branches.
Internally, when a git push is received by GitLab, the pre-receive hook checks whether the target ref (branch or tag) is protected. If it is, GitLab evaluates the pusher's role in the project against the allowed push access level. If the user's role is below the required level, the push is rejected with a clear error message. For merge operations, when the merge button is clicked in a merge request, GitLab checks the user's role against the allowed merge access level for the target branch. Protected branches also interact with CI/CD: only jobs running on protected branches can access protected CI/CD variables and use protected Runners. This creates a security boundary where production secrets are only available during deployments triggered from the main or release branches. Protected tags follow a similar model but control who can create tags matching specified patterns.
In production, a financial services company building trading-engine would configure a comprehensive branch protection strategy. The main branch allows no one to push directly (all changes must go through MRs) and requires Maintainers to merge, with additional approval rules requiring two backend team approvals. Release branches matching release/* are protected similarly, ensuring only release managers can merge hotfixes. The develop branch allows Developers to push for daily integration work but requires Maintainers to merge to main. Protected tags matching v* ensure only release managers can create version tags that trigger production deployments. Protected CI/CD variables like PRODUCTION_DB_PASSWORD and DEPLOY_KEY are scoped to protected branches, ensuring they are never exposed in feature branch pipelines. This layered protection prevents junior developers from accidentally deploying to production or accessing production credentials.
A common gotcha is the interaction between protected branches and CI/CD variables. If you mark a variable as protected, it is only available to jobs running on protected branches or tags. Developers working on feature branches will get empty values for these variables, causing confusing failures. Always use non-protected variables for non-sensitive values needed in feature branch pipelines, and document which variables are protected. Another mistake is protecting branches with overly broad wildcards like * which protects every branch, preventing developers from pushing to any branch at all. Be specific with patterns like release/* or hotfix/*.