GitLab review apps: a fresh environment per merge request

Spin up an ephemeral deploy for every merge request and tear it down on merge — review real changes, not screenshots.

Nov 12, 2025·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

A reviewer reading a diff is guessing whether the change works. A review app removes the guessing: the merge request's branch is deployed to its own namespace when the MR opens, the URL appears on the MR page, new commits redeploy it, and it is torn down when the MR merges or closes. GitLab gives you the lifecycle through two jobs that share an environment name. Most of the difficulty is in the second job, and most published examples get it wrong in a way that either deletes the preview immediately or never deletes it at all.

.gitlab-ci.yml
review:
stage: deploy
script:
- helm upgrade --install "mr-$CI_MERGE_REQUEST_IID" ./chart
--namespace "review-$CI_MERGE_REQUEST_IID" --create-namespace
--set host="$CI_COMMIT_REF_SLUG.review.acme.dev"
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.acme.dev
on_stop: stop_review
auto_stop_in: 1 week
resource_group: review/$CI_COMMIT_REF_SLUG
rules:
- if: $CI_MERGE_REQUEST_IID
stop_review:
stage: deploy
script:
- helm uninstall "mr-$CI_MERGE_REQUEST_IID" -n "review-$CI_MERGE_REQUEST_IID"
- kubectl delete namespace "review-$CI_MERGE_REQUEST_IID" --ignore-not-found
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
resource_group: review/$CI_COMMIT_REF_SLUG
rules:
- if: $CI_MERGE_REQUEST_IID
when: manual
allow_failure: true

Why the stop job is manual

The two jobs must carry the same rules, and the stop job must have a when. It is when: manual on purpose: with when: on_success the stop job would run as part of the same pipeline, right after the deploy job succeeds, and the reviewer would find an empty namespace. "Manual" here does not mean a human has to click it. GitLab runs the on_stop job itself when the merge request is merged or closed, when the branch is deleted, and when auto_stop_in expires; the manual setting only prevents it from running unprompted in the deploy pipeline. With rules, add allow_failure: true so a pipeline with a pending manual job still counts as complete for merge checks.

What tears a review environment down

TriggerMechanismNote
MR merged or closedGitLab runs the on_stop jobrequires merge request pipelines; the stop trigger is enabled automatically
Branch deletedGitLab runs the on_stop jobboth jobs need identical rules, or the stop job may be missing from the pipeline
auto_stop_in elapseda background worker schedules the stop jobthe worker runs hourly, so expiry is approximate; each deploy resets the timer
Stop button in Operate > Environmentsruns the on_stop jobdeploy and stop jobs must share a resource_group
Nothing configuredGitLab still marks the environment stopped on merge or branch deletethe environment record stops; your Helm release and namespace do not

The last row is the one that produces orphaned namespaces. GitLab's default stops the environment record when a branch is merged or deleted even with no on_stop job, which looks tidy in the UI while the cluster resources keep running. The stop job is what actually uninstalls the release, and auto_stop_in is the backstop for the draft MR nobody merges or closes.

The URL on the merge request page comes from environment:url, and for a review app it is rarely known before the deploy runs: the hostname depends on the branch slug, the ingress controller, sometimes a DNS record the job creates. GitLab's answer is a dotenv report: the deploy job writes DYNAMIC_ENVIRONMENT_URL=https://… to a file declared under artifacts:reports:dotenv, and when the job finishes GitLab expands environment:url: $DYNAMIC_ENVIRONMENT_URL from it. The reviewer clicks a link that was computed by the deploy rather than guessed by the pipeline author, which is the difference between a review app that gets used and one that gets ignored.

bash — confirm the environment exists, then that it went away
curl -sI https://feat-oauth.review.acme.dev | head -1
HTTP/2 200
kubectl get ns | grep review-
review-847 Active 2d
after merge: the stop_review job appears in the MR pipeline as run by GitLab, and the namespace is gone once it finishes
kubectl get ns review-847
Error from server (NotFound): namespaces "review-847" not found

Previews get preview credentials

A review app runs the code of an unmerged branch, so it must run with credentials whose loss is acceptable: a database created per namespace and destroyed with it, sandbox keys for payment or email providers, and no production kubeconfig or cloud role anywhere in the MR pipeline. Fork merge requests run in the fork project with the fork's variables by default; keep it that way, and if external contributions need a preview, deploy it only after a maintainer has read the change. Preview hostnames follow a predictable pattern, so anything with an admin interface needs authentication in the preview too, and the ingress for *.review.acme.dev is a reasonable place for an allowlist or an OAuth proxy.

Every open MR is a running stack
Set a ResourceQuota and a LimitRange on the review namespaces, cap concurrent environments per project, and let auto_stop_in expire the stale ones. Without those, abandoned draft merge requests become the largest line on the cluster bill, and the sweep that finally removes them is the moment someone discovers a reviewer was still using one.

Once previews are routine the same pipeline can carry a change further: a blue-green promotion after merge uses the same environment machinery, with protected environments in front of production instead of a throwaway namespace behind it.

Go deeper in a courseSecure CI/CD with GitLabEnvironments, review apps, protected deploys, scanning gates and runner isolation.View course

Related posts

Quick reference