Environments & Deployments
Environment Tracking
Review Apps
Deployment History
Rollbacks
Learn how GitLab tracks deployments across environments, how to configure review apps for merge requests, and how to roll back to a previous deployment when things go wrong.
Environments
Deployments
GitLab CI/CD
Why Environments Matter
A pipeline that deploys code is useful. A pipeline that tracks what it deployed, where, and when is transformative. GitLab environments turn deployments from fire-and-forget scripts into first-class objects with history, status, and rollback capabilities. Every time a job deploys to an environment, GitLab records the deployment: which commit, which job, which user, and when. This record becomes the foundation for auditing, debugging, and incident response.
Environments also unlock review apps — temporary environments created for each merge request, allowing reviewers to see changes running live before merging. And they enable environment-specific variables and protected environments, so production secrets are only available to authorized jobs on protected branches. When a deployment fails, the environment page shows exactly what's running and lets you redeploy or roll back to a previous version with a single click .
The keyword that ties this together is environment. Adding it to a deploy job tells GitLab that this job deploys to a named environment. From there, GitLab builds the deployment history, the environment dashboard, and the rollback interface automatically. Without it, you have deployments; with it, you have deployment management.
Core Principle: Every deploy job should have an environment block. It costs three lines of YAML and unlocks tracking, rollbacks, and environment-scoped variables.
Environment
A named deployment target (staging, production, review/app-123). Tracks deployment history and current state.
Deployment
A single record of a job deploying a commit to an environment. Includes commit, job, user, and timestamp.
Review App
A temporary environment created per merge request. Deleted automatically when the MR is merged or closed.
Rollback
Redeploy a previous commit to an environment. One click from the environment page, no pipeline needed.
Configuring Environments
The environment keyword is added to a job to mark it as a deployment. At its simplest, it takes a name: environment: staging. That single line tells GitLab to record the deployment, show it in the environment dashboard, and enable rollback. But the keyword supports much more: a URL that appears as a button in merge requests, a Kubernetes namespace for cluster integration, and an action that can be start, stop, or prepare .
The action: stop is particularly useful for review apps. When a merge request is merged or closed, GitLab runs the job marked with action: stop, which tears down the review environment. This keeps your infrastructure clean without manual cleanup. The on_stop keyword links the stop job to the deploy job, so GitLab knows which job to run when the environment needs to be removed .
Environment names are dynamic: you can use variables like $CI_COMMIT_REF_SLUG to create per-branch environments. This is the foundation of review apps — every branch gets its own environment name, its own URL, and its own deployment history, all from a single job definition.
# Basic environment
deploy_staging:
stage: deploy
script:
- ./deploy.sh staging
environment: staging
# Environment with URL (shown in MR)
deploy_staging:
stage: deploy
script:
- ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
# Dynamic environment (per branch)
deploy_review:
stage: deploy
script:
- ./deploy.sh review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
# Review app with automatic stop
deploy_review:
stage: deploy
script:
- ./deploy.sh review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review
stop_review:
stage: deploy
script:
- ./teardown.sh review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_MERGE_REQUEST_ID
when: manual
allow_failure: true
# Kubernetes environment (auto-linked to cluster)
deploy_k8s:
stage: deploy
script:
- kubectl apply -f k8s/
environment:
name: production
kubernetes:
namespace: production
agent: my-k8s-agent
# Environment with auto-stop timer
deploy_demo:
stage: deploy
script:
- ./deploy.sh demo
environment:
name: demo
url: https://demo.example.com
auto_stop_in: 1 week
# Environment actions:
# start (default): Deploy to environment
# stop: Stop/teardown environment
# prepare: Prepare environment (no deployment)
# verify: Verify environment
# Environment tiers (for dashboard grouping):
# production, staging, testing, development, other
environment:
name: production
deployment_tier: production
Environment Best Practices:
- Always add an
environment block to deploy jobs — even simple ones
- Include a
url so reviewers can click through from merge requests
- Use
action: stop with on_stop for review apps
- Use
deployment_tier to group environments in the dashboard
- Set
auto_stop_in for temporary environments to avoid orphaned infrastructure
Review Apps
Review apps are one of the most valuable features of GitLab environments. A review app is a temporary deployment created for a specific merge request, running the code from that MR's branch. Reviewers can click a link in the merge request and see the changes live — no need to check out the branch, install dependencies, and run it locally. Product managers can review UI changes without a developer's help. QA can test in a production-like environment before merge .
The mechanism is simple: define a deploy job with a dynamic environment name based on the branch or MR ID, and GitLab creates a new environment for each MR. When the MR is merged or closed, a corresponding stop job tears down the environment. The on_stop keyword links the two, so GitLab automatically runs the stop job when the MR is closed — no manual intervention needed.
Review apps typically require dynamic URLs, which often means wildcard DNS (e.g., *.review.example.com) and dynamic resource naming (Kubernetes namespaces, Docker container names, etc.). The specific implementation depends on your infrastructure, but the GitLab configuration is the same regardless: a deploy job with a dynamic environment name, and a stop job with action: stop.
# Review app deployment
deploy_review:
stage: deploy
image: alpine/helm:latest
script:
- helm upgrade --install review-$CI_COMMIT_REF_SLUG ./chart
--namespace review-$CI_COMMIT_REF_SLUG
--create-namespace
--set image.tag=$CI_COMMIT_SHORT_SHA
--set ingress.host=$CI_COMMIT_REF_SLUG.review.example.com
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Stop review app
stop_review:
stage: deploy
image: alpine/helm:latest
script:
- helm uninstall review-$CI_COMMIT_REF_SLUG
--namespace review-$CI_COMMIT_REF_SLUG
- kubectl delete namespace review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
# Review app with Docker Compose
deploy_review_docker:
stage: deploy
script:
- docker-compose -p review-$CI_COMMIT_REF_SLUG up -d
environment:
name: review/$CI_COMMIT_REF_SLUG
url: http://$CI_COMMIT_REF_SLUG.review.example.com:8080
on_stop: stop_review_docker
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop_review_docker:
stage: deploy
script:
- docker-compose -p review-$CI_COMMIT_REF_SLUG down -v
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
# Review app with auto-stop (even if MR stays open)
deploy_review:
stage: deploy
script:
- ./deploy.sh review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review
auto_stop_in: 3 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Variables available for review apps:
# $CI_COMMIT_REF_SLUG - Branch name, slugified
# $CI_MERGE_REQUEST_IID - MR number
# $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME - Source branch
# $CI_MERGE_REQUEST_TARGET_BRANCH_NAME - Target branch
Review App Requirements:
- Dynamic URLs: Wildcard DNS or a routing layer (e.g., Traefik, NGINX Ingress)
- Dynamic resources: Kubernetes namespaces, Docker project names, or similar isolation
- Cleanup: A stop job linked with
on_stop so environments don't accumulate
- Cost control: Consider
auto_stop_in to prevent forgotten environments from running indefinitely
Deployment History & Rollbacks
Every deployment to an environment is recorded by GitLab, creating a history that shows what was deployed, when, by whom, and from which commit. This history is visible on the environment page (Operate → Environments) and on each environment's detail page. For each deployment, you can see the commit SHA, the job that performed it, the user who triggered it, and the deployment status (success, failed, running, canceled) .
The most powerful use of this history is rollback. When a deployment causes problems in production, you don't need to revert the commit and run a new pipeline. Instead, you open the environment page, find the last known-good deployment, and click "Rollback". GitLab creates a new deployment that re-runs the deploy job from that previous commit, restoring the environment to its prior state. The rollback itself is recorded as a new deployment, preserving the full audit trail.
Rollbacks work because deploy jobs are deterministic: given a commit, they produce the same result. This is why it's important that deploy jobs don't depend on mutable state (like "latest" tags or external files that change). A rollback re-runs the exact job from the previous commit, which deploys the same image, same configuration, same everything . For this reason, pinning image tags to commit SHAs (not latest) is essential for reliable rollbacks.
# Deployment history is automatic when you use environment:
deploy_production:
stage: deploy
script:
- ./deploy.sh production $CI_COMMIT_SHORT_SHA
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
# Rollback via UI:
# 1. Go to Operate → Environments
# 2. Click on the environment (e.g., "production")
# 3. Find the last successful deployment before the bad one
# 4. Click "Rollback"
# GitLab re-runs the deploy job from that commit
# Rollback via API:
# POST /projects/:id/environments/:environment_id/stop
# POST /projects/:id/deployments/:deployment_id/rollback
# View deployment history via API:
# GET /projects/:id/deployments?environment=production
# Ensure rollbacks work: use immutable image tags
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
deploy:
stage: deploy
script:
# Good: pinned to commit SHA, rollback deploys exact same image
- kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# Bad: "latest" tag, rollback may deploy a different image
# - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:latest
# Deployment statuses:
# running - deployment in progress
# success - deployment completed successfully
# failed - deployment failed
# canceled - deployment was canceled
# blocked - deployment blocked by protected environment
# Environment status:
# available - last deployment succeeded
# stopping - stop job running
# stopped - environment stopped
# unavailable - environment unavailable
# Protected environments:
# Only users with permission can deploy
# Configured in Settings → CI/CD → Protected environments
# - Allowed to deploy: Maintainers
# - Required approvals: 1
# - Prevented from deploying: Developers
Rollback Requirements:
- Immutable artifacts: Use commit SHA tags, not
latest, so rollbacks deploy the exact same image
- Deploy job runs from history: The rollback re-runs the deploy job from the old commit
- Job must still exist: If the deploy job was removed from the pipeline, rollback may fail
- Protected environments: Rollbacks to protected environments may require approvals
Environment Variables & Protection
Environments become truly powerful when combined with scoped variables. GitLab lets you define CI/CD variables that are only available to jobs deploying to a specific environment. A DATABASE_URL variable scoped to production won't be available to a staging deploy job — reducing the blast radius if a pipeline is compromised. This is essential for multi-environment setups where each environment uses different credentials, different endpoints, and different secrets .
Protected environments add another layer of security. When you mark an environment as protected, only users with specific roles (usually Maintainers or Owners) can deploy to it. You can also require approvals: a deployment to production may need one or more approvals from designated users before it proceeds. Combined with protected branches (so the deploy job only runs on main), this creates a secure path to production where unauthorized users cannot deploy and required reviewers cannot be bypassed .
Environment-scoped variables are configured in Settings → CI/CD → Variables, where you can scope a variable to a specific environment. The variable is then available only to jobs with that environment value. Note that environment-scoped variables must be unique across environments — you cannot have two variables with the same key scoped to different environments .
# Environment-scoped variables (configured in UI)
# Settings → CI/CD → Variables
#
# Variable: DATABASE_URL
# Value: postgres://prod-db.example.com:5432/app
# Environment scope: production
#
# Variable: DATABASE_URL
# Value: postgres://staging-db.example.com:5432/app
# Environment scope: staging
#
# The same key can have different values per environment
# Using environment-scoped variables in jobs
deploy_production:
stage: deploy
script:
- echo "Connecting to $DATABASE_URL" # production DB
- ./deploy.sh production
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy_staging:
stage: deploy
script:
- echo "Connecting to $DATABASE_URL" # staging DB
- ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
# Protected environments (configured in UI)
# Settings → CI/CD → Protected environments
#
# Environment: production
# Allowed to deploy: Maintainers
# Required approvals: 1
# Approvers: @tech-lead, @cto
#
# Only Maintainers can deploy; requires 1 approval
# Environment-specific variables in .gitlab-ci.yml
# (not recommended for secrets, but useful for config)
deploy_production:
stage: deploy
variables:
DEPLOY_ENV: "production"
LOG_LEVEL: "warn"
script:
- ./deploy.sh $DEPLOY_ENV
environment:
name: production
# Protected variable example (UI)
# Key: PROD_API_KEY
# Value: sk_live_xxx
# Type: Variable
# Environment scope: production
# Protected: ✓ (only on protected branches)
# Masked: ✓ (hidden in logs)
# Environment scope wildcards:
# production - exact match
# production/* - all environments starting with production/
# * - all environments (global)
# Note: environment-scoped variables must be unique
# You cannot define the same key twice for different environments
# in the UI. Use different keys or environment-specific config files.
Environment Security Best Practices:
- Mark
production as a protected environment
- Require approvals for production deploys
- Scope secrets to their specific environment
- Use masked variables for all sensitive data
- Combine protected environments with protected branches
- Audit who can deploy to production in project settings
Complete Environments Pipeline Example
Here's a complete pipeline that demonstrates review apps, staging, production, environment-scoped variables, and rollback-ready deployments.
# Complete environments pipeline
stages:
- build
- test
- deploy
# Build with immutable tags (enables rollbacks)
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
test:
stage: test
script:
- npm test
# Review app: one per MR, auto-stopped
deploy_review:
stage: deploy
image: alpine/helm:latest
script:
- helm upgrade --install review-$CI_COMMIT_REF_SLUG ./chart
--namespace review-$CI_COMMIT_REF_SLUG
--create-namespace
--set image.tag=$CI_COMMIT_SHORT_SHA
--set ingress.host=$CI_COMMIT_REF_SLUG.review.example.com
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop_review
auto_stop_in: 3 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop_review:
stage: deploy
image: alpine/helm:latest
script:
- helm uninstall review-$CI_COMMIT_REF_SLUG
--namespace review-$CI_COMMIT_REF_SLUG
- kubectl delete namespace review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
# Staging: auto-deploy on main
deploy_staging:
stage: deploy
image: alpine/helm:latest
script:
- helm upgrade --install app ./chart
--namespace staging
--set image.tag=$CI_COMMIT_SHORT_SHA
--wait --timeout 5m
environment:
name: staging
url: https://staging.example.com
deployment_tier: staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
# Production: manual, protected, with approvals
deploy_production:
stage: deploy
image: alpine/helm:latest
script:
- helm upgrade --install app ./chart
--namespace production
--set image.tag=$CI_COMMIT_SHORT_SHA
--atomic --wait --timeout 10m
environment:
name: production
url: https://example.com
deployment_tier: production
rules:
- if: $CI_COMMIT_TAG
when: manual
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
# Result:
# - Every MR gets a review app (auto-stopped after 3 days)
# - Main branch auto-deploys to staging
# - Production deploys are manual (tag or main)
# - Production is a protected environment (approvals required)
# - All deployments use commit SHA tags (rollback-safe)
# - Every deployment is recorded in the environment history
Troubleshooting Environments
# Environment not appearing in dashboard
# - Check job has environment keyword
# - Check job actually ran (not skipped by rules)
# - Check job succeeded (failed deployments may not show)
# Rollback button not available
# - Check there's a previous successful deployment
# - Check you have permission (Maintainer+)
# - Check environment is not stopped
# - Check deploy job still exists in pipeline
# Rollback fails
# - Check image tag is still available in registry
# - Check deploy job from old commit still works
# - Check variables haven't changed
# - Use immutable tags (not "latest")
# Review app not stopping
# - Check on_stop is set on deploy job
# - Check stop job has action: stop
# - Check stop job rules match (MR event)
# - Check stop job ran (may be manual)
# Environment-scoped variable not available
# - Check scope matches environment name exactly
# - Check job has correct environment name
# - Check variable is not protected (if on unprotected branch)
# - Check variable is not masked (may cause issues)
# Protected environment blocks deployment
# - Check user role (Maintainer+ usually required)
# - Check approvals (may need 1+ approvals)
# - Check branch is protected (if required)
# - Check deployment is not from unauthorized source
# Environment URL not showing in MR
# - Check url is set in environment block
# - Check URL is valid (https://)
# - Check environment was deployed successfully
# - Check MR is still open
# Deployment history missing
# - Check environment keyword is present
# - Check job succeeded (failed jobs may not record)
# - Check you're looking at the right environment
# - Check deployment retention settings
# Debug: list environments via API
# GET /projects/:id/environments
# GET /projects/:id/deployments
# Debug: check environment in job log
script:
- echo "Environment: $CI_ENVIRONMENT_NAME"
- echo "URL: $CI_ENVIRONMENT_URL"
- echo "Action: $CI_ENVIRONMENT_ACTION"
Frequently Asked Questions
What's the difference between an environment and a deployment?
An environment is a named target (staging, production, review/app-123). A deployment is a single record of a job deploying a commit to that environment. One environment has many deployments over time.
Do I need environments if I only have one deploy target?
Yes. Even with one target, environments give you deployment history, rollback capability, and a place to scope variables. It's three lines of YAML for significant operational benefits.
How do I roll back a deployment?
Go to Operate → Environments, click the environment, find the last successful deployment before the bad one, and click "Rollback". GitLab re-runs the deploy job from that commit.
Why do rollbacks sometimes deploy the wrong code?
If your deploy job uses latest tags instead of commit SHA tags, a rollback may pull a different image. Always tag images with the commit SHA for reliable rollbacks.
How do I create a review app?
Add an environment block with a dynamic name (e.g., review/$CI_COMMIT_REF_SLUG) and an on_stop job with action: stop. The environment is created per MR and torn down when the MR closes.
How do I protect the production environment?
Go to Settings → CI/CD → Protected environments, add production, and configure who can deploy and how many approvals are required.
Can I have different variables per environment?
Yes. In Settings → CI/CD → Variables, set the environment scope when creating a variable. The variable is then available only to jobs with that environment name.
What happens to a review app when the MR is merged?
GitLab automatically runs the stop job (linked via on_stop), which tears down the environment. If the stop job is manual, you need to trigger it yourself.
Environments turn deployments into trackable, reversible events. Add the environment keyword and unlock history, rollbacks, and review apps.