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.
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.
review:stage: deployscript:- 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_SLUGurl: https://$CI_COMMIT_REF_SLUG.review.acme.devon_stop: stop_reviewauto_stop_in: 1 weekresource_group: review/$CI_COMMIT_REF_SLUGrules:- if: $CI_MERGE_REQUEST_IIDstop_review:stage: deployscript:- helm uninstall "mr-$CI_MERGE_REQUEST_IID" -n "review-$CI_MERGE_REQUEST_IID"- kubectl delete namespace "review-$CI_MERGE_REQUEST_IID" --ignore-not-foundenvironment:name: review/$CI_COMMIT_REF_SLUGaction: stopresource_group: review/$CI_COMMIT_REF_SLUGrules:- if: $CI_MERGE_REQUEST_IIDwhen: manualallow_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
| Trigger | Mechanism | Note |
|---|---|---|
| MR merged or closed | GitLab runs the on_stop job | requires merge request pipelines; the stop trigger is enabled automatically |
| Branch deleted | GitLab runs the on_stop job | both jobs need identical rules, or the stop job may be missing from the pipeline |
auto_stop_in elapsed | a background worker schedules the stop job | the worker runs hourly, so expiry is approximate; each deploy resets the timer |
| Stop button in Operate > Environments | runs the on_stop job | deploy and stop jobs must share a resource_group |
| Nothing configured | GitLab still marks the environment stopped on merge or branch delete | the 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.
curl -sI https://feat-oauth.review.acme.dev | head -1HTTP/2 200kubectl get ns | grep review-review-847 Active 2dafter merge: the stop_review job appears in the MR pipeline as run by GitLab, and the namespace is gone once it finisheskubectl get ns review-847Error from server (NotFound): namespaces "review-847" not foundPreviews 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.
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