Review Apps, Dynamic Environments, Ephemeral Test Stacks, Route Maps, and Environment Cleanup: Guided Hands-On Workflow and Core Operations
Build a disposable MR-like review environment locally, map it to GitLab dynamic-environment metadata, inspect slug and lifecycle evidence, then stop it and prove the exact synthetic resource is gone.
Learning objectives
- Create two bounded local review targets keyed by synthetic MR identifiers using guarded filesystem operations and no cloud account.
- Declare a GitLab MR review environment with deterministic name, safe URL metadata, on_stop, auto_stop_in, and source-aware rules.
- Inspect CI_PIPELINE_SOURCE, CI_COMMIT_SHA, merge-request identity, CI_ENVIRONMENT_NAME, CI_ENVIRONMENT_SLUG, URL metadata, and synthetic resource manifest.
- Add a route map that links repository paths to review-app public paths without confusing route mapping with deployment.
- Stop and remove only the intended synthetic review resource, then independently verify absence and preserve cleanup evidence.
1. Lab scenario: a disposable review app without cloud dependencies
This lab uses a synthetic static site and a guarded local “provider”
rooted under .sandbox/ch20-reviews. The provider is
intentionally boring: each review resource is one directory named
mr-N, containing a copied site and a manifest that
records source identity. This makes lifecycle logic observable
without requiring DNS, Kubernetes, a cloud subscription, or
privileged runners.
GitLab YAML then models the same identity as
review/mr-$CI_MERGE_REQUEST_IID and uses a fake
.invalid URL. When you have real review infrastructure,
replace only the provider adapter; preserve the identity, evidence,
stop, and cleanup contract.
2. Preflight and assumptions
| Check | Required evidence | Why |
|---|---|---|
| Disposable project/branch | A throwaway project or course lab branch. | No production repo mutation. |
| Pipeline source | MR pipeline: merge_request_event. |
MR IID/ref variables are predictable. |
| Runner | Any ordinary non-privileged runner or local shell for simulation. | No Docker socket, privileged container, or cloud admin needed. |
| Tools |
POSIX shell, sha256sum (or local equivalent),
basic file utilities.
|
Synthetic static content only. |
| Credentials | None. | Prevents accidental real deployment. |
| Target root |
$CI_PROJECT_DIR/.sandbox/ch20-reviews or a
local temp directory.
|
Exact bounded cleanup root. |
| URL | *.review.example.invalid. |
Cannot accidentally become production traffic. |
3. Create synthetic source and route-map inputs
mkdir -p demo-site/docs .gitlab ci
printf '%s\n' '<!doctype html><title>Review home</title><h1>Review home</h1>' > demo-site/index.html
printf '%s\n' '<!doctype html><title>Guide</title><h1>Guide</h1>' > demo-site/docs/guide.html
cat > .gitlab/route-map.yml <<'EOF'
- source: 'demo-site/index.html'
public: '/'
- source: /demo-site\/docs\/(.+)\.html/
public: '/docs/\1.html'
EOF
sha256sum demo-site/index.html demo-site/docs/guide.html
The hashes are content evidence, not environment identity. The route map is checked into the repository so its provenance follows the same source SHA as the preview content. It does not create the preview target.
4. Build a guarded local provider adapter
cat > ci/reviewctl.sh <<'EOF'
#!/bin/sh
set -eu
ROOT="${REVIEW_ROOT:-$PWD/.sandbox/ch20-reviews}"
action="${1:?action required}"
id="${2:?review id required}"
sha="${3:-unknown}"
case "$id" in
mr-[0-9]*) ;;
*) echo "refusing unsafe review id: $id" >&2; exit 64 ;;
esac
target="$ROOT/$id"
case "$target" in
"$ROOT"/mr-*) ;;
*) echo "refusing target outside review root" >&2; exit 65 ;;
esac
case "$action" in
deploy)
mkdir -p "$target/site"
cp -R demo-site/. "$target/site/"
printf 'review_id=%s\nsource_sha=%s\n' "$id" "$sha" > "$target/manifest.txt"
find "$target" -type f -maxdepth 3 -print | sort
;;
inspect)
test -d "$target"
cat "$target/manifest.txt"
;;
destroy)
test -f "$target/manifest.txt"
grep -qx "review_id=$id" "$target/manifest.txt"
rm -rf -- "$target"
test ! -e "$target"
printf 'cleanup_verified=%s\n' "$id"
;;
*) echo "unknown action: $action" >&2; exit 66 ;;
esac
EOF
chmod +x ci/reviewctl.sh
The deletion is deliberately guarded twice: the ID must match
mr-[0-9]*, and the computed path must remain under the
exact review root. The manifest must also name the same resource
before deletion. This is a teaching guard, not a universal cloud
deletion strategy.
5. Prove the lifecycle locally before involving GitLab
export REVIEW_ROOT="$(pwd)/.sandbox/ch20-reviews"
rm -rf -- "$REVIEW_ROOT"
mkdir -p "$REVIEW_ROOT"
./ci/reviewctl.sh deploy mr-101 deadbeef101
./ci/reviewctl.sh inspect mr-101
find "$REVIEW_ROOT" -mindepth 1 -maxdepth 2 -print | sort
./ci/reviewctl.sh destroy mr-101
find "$REVIEW_ROOT" -mindepth 1 -print | sort
Expected evidence: deployment lists only mr-101;
inspection returns source_sha=deadbeef101; after
destroy, the bounded root contains no review resource. This is the
independent cleanup proof that a GitLab “stopped” badge cannot
provide by itself.
6. Map the same contract into an MR pipeline
stages: [test, deploy]
default:
image: alpine:3.22
review_smoke:
stage: test
script:
- test -f demo-site/index.html
- sha256sum demo-site/index.html demo-site/docs/guide.html
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
review_deploy:
stage: deploy
resource_group: "review-mr-$CI_MERGE_REQUEST_IID"
script:
- printf 'pipeline_source=%s\n' "$CI_PIPELINE_SOURCE"
- printf 'source_sha=%s\n' "$CI_COMMIT_SHA"
- printf 'mr_iid=%s\n' "$CI_MERGE_REQUEST_IID"
- printf 'environment_name=%s\n' "$CI_ENVIRONMENT_NAME"
- printf 'environment_slug=%s\n' "$CI_ENVIRONMENT_SLUG"
- printf 'resource_id=mr-%s\n' "$CI_MERGE_REQUEST_IID" | tee review-resource.txt
artifacts:
paths: [review-resource.txt]
expire_in: 7 days
environment:
name: "review/mr-$CI_MERGE_REQUEST_IID"
url: "https://$CI_ENVIRONMENT_SLUG.review.example.invalid/"
on_stop: review_stop
auto_stop_in: 2 days
deployment_tier: development
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
review_stop:
stage: deploy
resource_group: "review-mr-$CI_MERGE_REQUEST_IID"
script:
- printf 'requested_cleanup=mr-%s\n' "$CI_MERGE_REQUEST_IID"
- printf 'GitLab stop metadata only; verify provider deletion independently.\n'
environment:
name: "review/mr-$CI_MERGE_REQUEST_IID"
action: stop
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: manual
allow_failure: true
This pipeline intentionally does not pretend the GitLab runner filesystem is a persistent external hosting provider. It demonstrates GitLab environment state, review identity, URL metadata, the stop relationship, and the evidence manifest. The local adapter demonstrates the real create/delete contract. In production, the deploy/stop scripts would call an isolated provider API using short-lived scoped identity.
7. Which layer each keyword changes
| Element | Reads/changes | Why it exists |
|---|---|---|
rules |
Pipeline compilation/job inclusion. | Only MR pipelines create review lifecycle jobs. |
resource_group |
GitLab concurrency queue for a logical side effect. | Deploy and stop for one review cannot race; UI stop/on-stop requirement is satisfied. |
environment:name |
GitLab environment identity. | Stable mapping from MR to review environment. |
environment:url |
GitLab navigation metadata. | Reviewers get a destination; it does not provision hosting. |
on_stop |
Lifecycle link to teardown job. | GitLab knows which job represents stop action. |
auto_stop_in |
Environment expiration schedule. | Backstop for forgotten manual cleanup. |
deployment_tier |
Environment classification. | Marks preview as development, not production. |
| artifact manifest | GitLab artifact/evidence state. | Carries bounded resource identity; not the external resource itself. |
8. Inspect the created environment before stopping it
After the MR pipeline runs, inspect the pipeline and job IDs, then open Operate → Environments. Record the environment name, slug, URL, state, latest deployment, source SHA/ref, and the stop action. The fake URL is expected not to resolve; the point is to prove GitLab metadata and identity without creating live infrastructure.
From the job trace, preserve only the explicit non-secret lines above. If you have API access in a disposable project, a read-only environment query can add environment ID/state to the evidence packet; never introduce a broad PAT solely for the exercise.
9. Verify route-map behavior conceptually and locally
The route map associates repository paths with public paths. For the
lab mappings, demo-site/index.html maps to
/ and demo-site/docs/guide.html maps to
/docs/guide.html. In a real reachable review app,
GitLab can surface mapped pages in the MR/file UI. In this
simulation, verify the mapping file at the exact source SHA and
verify the corresponding file exists in the local synthetic
resource.
test -f .gitlab/route-map.yml
./ci/reviewctl.sh deploy mr-102 deadbeef102
test -f .sandbox/ch20-reviews/mr-102/site/docs/guide.html
printf 'mapped_public_path=/docs/guide.html\n'
./ci/reviewctl.sh destroy mr-102
10. Cleanup sequence and proof
- Preserve pipeline/job/environment IDs and the synthetic resource manifest.
-
Trigger the exact
review_stopaction or allow lifecycle/expiry logic to request stop. - Run the provider-specific teardown for the exact recorded resource ID.
- Query the bounded provider inventory again.
- Record GitLab environment state and external absence separately.
- Only after evidence is saved, remove the disposable branch/project if desired.
If a real provider returns asynchronous deletion, “request accepted” is not final proof. Poll only that exact resource until the terminal absent/deleted state or record the provider operation ID for later reconciliation.
11. Small challenge: choose the layer, not a copied command sequence
Your review URL is correct and GitLab shows the environment as available, but reviewers see the previous commit. Which layer should you investigate first?
Answer after reasoning: start with
source/artifact/external-target identity, not on_stop.
Prove the deployment job SHA, the artifact/content digest, and what
the external target currently serves. The environment URL only
points to the intended target; it does not guarantee the target
contains the current bytes.
12. Verification checklist
-
MR pipeline only:
CI_PIPELINE_SOURCE=merge_request_event. -
Review name is
review/mr-<iid>; resource identity ismr-<iid>. - No raw branch string is used as a deletion path.
-
review_deployandreview_stopshare the same MR rule and resource group. -
auto_stop_inis present as a backstop, not treated as exact-time deletion proof. - Route map is versioned at the same source SHA as the content.
- Cleanup proof is an independent inventory/absence check.
- No real credential, DNS zone, Kubernetes cluster, or cloud account was required.
Knowledge check
Why does the GitLab YAML lab not copy the local provider directory between jobs?
Runner workspaces are not a durable external hosting system. The YAML models GitLab lifecycle metadata; the local adapter separately demonstrates actual bounded create/delete behavior.
Why do deploy and stop share resource_group?
It serializes side effects for one review identity and supports current UI-triggered on_stop behavior that requires the same resource group.
What evidence proves local cleanup?
The exact manifest is validated, the exact target directory is deleted, and a bounded inventory confirms that target no longer exists.
Why is the environment URL under .invalid?
It is safe metadata for training and cannot accidentally point at a production service or create live traffic expectations.
If the preview serves old bytes, is on_stop the first layer to debug?
No. First verify source SHA, artifact/content digest, deployment action, and external target state; URL/lifecycle metadata can be correct while content is stale.
Version and compatibility note
GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.
Official references and version notes
Documentation verification date: 2026-09-12. Review-app, environment lifecycle, variable, protected-resource, route-map, and cleanup semantics are version-sensitive. Re-check the GitLab version used by your organization before copying exact lifecycle behavior into production.
- Review apps — dynamic review environments, merge-request workflows, stop behavior, and route maps.
-
Environments
— static/dynamic environments, environment states,
on_stop,auto_stop_in, stale cleanup, deletion, and environment-scoped variables. -
CI/CD YAML syntax reference
—
environment:name,url,on_stop,action,auto_stop_in,resource_group, andrules. -
Predefined CI/CD variables
—
CI_COMMIT_REF_SLUG,CI_ENVIRONMENT_NAME,CI_ENVIRONMENT_SLUG,CI_ENVIRONMENT_URL, and merge-request variables. - CI/CD variables — protection, masking/hidden behavior, fork/MR exposure, and environment scope.
- Environments API — environment metadata, stop, and stale-environment operations when API automation is appropriate.
- Deployment safety — protected-resource and deployment-safety boundaries that also matter for review infrastructure.
Current assumptions used in this chapter: review
apps and dynamic environments are available on Free, Premium, and
Ultimate across GitLab.com, Self-Managed, and Dedicated.
CI_COMMIT_REF_SLUG is normalized and shortened to 63
bytes; CI_ENVIRONMENT_SLUG is derived from
environment:name, is truncated to 24 characters, and
uppercase environment names can receive a random suffix. Route maps
live in .gitlab/route-map.yml, are evaluated in
declaration order, and the first matching source rule determines the
public path; the merge-request widget can surface up to five mapped
pages before filtering.
environment:auto_stop_in accepts human-readable
durations (and variables), but environment expiration is serviced by
background work that runs approximately hourly, so it is not an
exact timer. Deploy and stop jobs should have compatible
rules/only/except; a stop job
also has to be runnable when cleanup is needed. To trigger
on_stop from the Environments UI, deploy and stop jobs
must share a resource_group. Protected environments are
Premium/Ultimate and are not required by the mandatory lab. The
mandatory path uses fake .invalid URLs and guarded
local filesystem resources under a temporary sandbox—no cloud
account, Kubernetes cluster, production DNS, PAT, deploy token, or
real secret is required.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.