Chapter 20Lesson 02~205 minutes

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.

Hands-onMR pipelineson_stopauto_stop_inCleanup proof

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

  1. Preserve pipeline/job/environment IDs and the synthetic resource manifest.
  2. Trigger the exact review_stop action or allow lifecycle/expiry logic to request stop.
  3. Run the provider-specific teardown for the exact recorded resource ID.
  4. Query the bounded provider inventory again.
  5. Record GitLab environment state and external absence separately.
  6. 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 is mr-<iid>.
  • No raw branch string is used as a deletion path.
  • review_deploy and review_stop share the same MR rule and resource group.
  • auto_stop_in is 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?

Why do deploy and stop share resource_group?

What evidence proves local cleanup?

Why is the environment URL under .invalid?

If the preview serves old bytes, is on_stop the first layer to debug?

Next lesson

Configuration, design choices, and tradeoffs

Choose isolation, stop strategy, naming/DNS, route maps, credential scope, TTL, and resource budgets from observable operational requirements.

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, and rules.
  • 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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.