Stop Racing Deploys in GitLab with resource_group
Two pipelines, one production environment: how GitLab's resource_group keyword serializes deploy jobs, plus the trade-offs and limits to know first.
14 May 2026, 08:29 UTC

Two merge requests merge ten minutes apart. Both pipelines clear build, reach the deploy stage, and now two jobs are pushing to production simultaneously: releases interleave, a database migration lands halfway through another job's rollout, and someone re-runs the pipeline and hopes. If you deploy from GitLab CI/CD to a shared environment, this race is a matter of when, not if.
GitLab ships a built-in fix: the resource_group keyword. One line on a deploy job makes every job declaring the same value run one at a time — no external lock service, no hand-rolled scripts. Here's what it does, a config you can adapt, and the trade-offs to weigh first.
What resource_group actually does
Jobs that declare the same resource_group value are serialized across all pipelines of a project — not just within one pipeline. The first job to start holds the resource; the others stay pending instead of running, so a queued deploy doesn't occupy a runner or block unrelated jobs.
The value is an arbitrary string. Most teams name it after the environment and pair it with environment:, so production deploys queue behind each other while a differently named staging group runs independently.
Newer GitLab releases also add process mode options that control which waiting job runs next (oldest-first versus newest-first, for example). Availability and defaults differ between releases and between GitLab.com and self-managed instances, so treat ordering as version-sensitive and check the resource_group section of the docs for your instance before relying on any specific queue order.
A worked example: serializing production deploys
The keyword goes in .gitlab-ci.yml at the repository root; changing it requires push access to the repo, directly or through a merge request.
stages:
- build
- deploy
build-app:
stage: build
script:
- ./scripts/build.sh
interruptible: true
deploy-production:
stage: deploy
script:
- ./scripts/deploy.sh production
environment: production
resource_group: production
timeout: 30 minutes
environment: productionrecords each deployment against the named environment in GitLab's UI.resource_group: productionis the serialization itself: any other job in this project declaring the same value waits its turn.interruptible: trueon the build job lets a newer pipeline cancel a superseded build before it reaches deploy, so stale pipelines don't pile up in the queue.timeout: 30 minutescaps the damage of a hung deploy: the job fails, releases the resource, and the queue moves.
The deploy job itself stays non-interruptible (the default), so once a production deploy is running, a newer pipeline can't kill it mid-rollout.
Validate before merging: paste the file into the pipeline editor (Build → Pipeline editor) or the CI lint page — both require at least Developer role in the project — and confirm it parses on your instance. The snippet is a pattern to adapt, not a tested config; script names and timeout values should match your setup.
The trade-off: queue time, and one job that can block them all
Serialization buys order with latency. When two pipelines reach deploy together, the second waits for the first to finish. A five-minute deploy makes that trivial; a forty-minute release makes it very visible. That's the deal: predictable, ordered deploys in exchange for queue time.
The sharper risk is a stuck job. Everything behind it in the group waits, so a hung deploy stalls the entire queue. The job-level timeout caps that, and pairing the group with failure alerting means a failed deploy gets noticed before the backlog grows.
If you change your mind, removing the resource_group line returns the job to parallel execution on the next pipeline.
What it won't protect you from
resource_group is ordering within one project's pipelines, not a distributed lock. It won't serialize deploys that bypass CI — a manual kubectl apply, a cron script, another CI system — or deploys coming from other projects in a multi-project setup. Those patterns still need a lock at the infrastructure or deploy-tooling layer.
It also complements rather than replaces protected environments and deployment approvals. Approvals control who may deploy to an environment; the resource group controls in what order approved deploys run. Used together, they gate the human decision and stop two approved deploys from colliding.
Verify it before you rely on it
The behavior is cheap to confirm:
- Find your instance version on its Help page and open the CI/CD YAML keyword reference for that version; read the
resource_groupsection, including any process mode options it lists. - In a scratch project, give two jobs that each sleep for a minute the same
resource_group, trigger two pipelines at once, and watch the second job wait instead of run. - Lint your real change in the pipeline editor, then apply it to the deploy job for your most contention-prone environment first.
One line turns a deploy stage from a free-for-all into an orderly queue. It won't cover out-of-band deploys or replace approvals, but for the common case — one project, one shared environment, CI-triggered deploys — it's the cheapest concurrency fix in GitLab's toolbox.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.