BLOG/TEAM CI/CD
THEME
TEAM CI/CD

Ephemeral preview environments per PR

Spin up a throwaway environment when a PR opens and tear it down when it closes — namespace, deploy target, and URL all derived from the one input that matters: the PR number.

May 26, 2026 OrchStep Team 6 minROLE: Platform EngineerSCALE: Scale-up
RUNNABLE DEMO
Full source for this post: blog/preview-environments
VIEW SOURCE

Preview environments are the feature reviewers love and platform teams dread. Every PR gets its own running copy of the app at its own URL, so a reviewer clicks a link instead of pulling the branch. Wonderful — until you're the one keeping the create-and-destroy logic alive across two CI jobs, a pile of if/sed, and a teardown step that silently breaks and leaves forty orphaned namespaces burning budget.

At a scale-up, this is a real line item. The trick that keeps it sane is realizing the whole thing has exactly one input: the PR number. The namespace, the deploy target, the hostname — all of it derives from that one integer. This post writes the spin-up and teardown as two tasks that take the PR number and compute the rest.

Two tasks: up and down

up runs when a PR opens or gets new commits; down runs when it closes. Both take --var pr=<number> and derive everything else from it:

orchstep.yml
name: preview
# Spin one up:  orchstep run up   --var pr=482
# Tear it down: orchstep run down --var pr=482
defaults:
  pr: "0"
  app: acme-web

tasks:
  up:
    steps:
      # Derive a stable namespace from the PR number.
      - name: namespace
        func: shell
        do: echo "pr-{{ vars.pr }}"
        outputs:
          ns: '{{ result.output | trim }}'

      - name: provision
        func: shell
        do: echo "creating preview env {{ steps.namespace.ns }} for {{ vars.app }}"

      - name: deploy
        func: shell
        do: echo "deploying {{ vars.app }} into {{ steps.namespace.ns }}"

      - name: url
        func: shell
        do: echo "preview ready at https://{{ steps.namespace.ns }}.preview.acme.dev"

  down:
    steps:
      - name: teardown
        func: shell
        do: echo "tearing down preview env pr-{{ vars.pr }} for {{ vars.app }}"

      - name: confirm
        func: shell
        do: echo "preview env pr-{{ vars.pr }} removed"

What makes this hold together:

  • The PR number is the only input. Everything downstream — the pr-482 namespace, the deploy target, the pr-482.preview.acme.dev hostname — is computed from vars.pr. There is no second source of truth to drift out of sync.
  • The namespace is computed once. The namespace step emits pr-<number> and captures it as steps.namespace.ns, so every later step references the same derived value instead of re-deriving it (and re-deriving it slightly differently).
  • up and down are symmetric. Same input, opposite operation. The teardown isn't a forgotten afterthought in a different file — it's a sibling task that takes the identical --var pr=.

Wire it to PR events

Your CI calls these on the matching webhook events. From a GitHub Actions step, that's roughly:

# on pull_request: opened, synchronize
orchstep run up --var pr=${{ github.event.number }}

# on pull_request: closed
orchstep run down --var pr=${{ github.event.number }}

The same two commands work from your laptop, which is the part that saves the platform team's sanity. When a teardown misbehaves in CI you can run orchstep run down --var pr=482 by hand and watch exactly what it does, instead of re-triggering a whole pipeline to debug one step. (Calling the same workflow from CI and locally is covered in the agents and CI overview.)

Preview the spin-up before it spins

orchstep run up --var pr=482 --dry-run

The plan resolves pr-482, the deploy target, and the full preview URL without provisioning anything — so you can confirm the hostname scheme and namespace naming are right before a single resource is created. See Previewing with Dry Run.

What you actually gained

ConcernTwo CI jobs + glueOrchStep
Inputsscattered env varsone --var pr=
Namespace / URL namingre-derived per stepcomputed once, reused
Teardowna separate, fragile joba symmetric down task
Debugging a stuck teardownre-run the pipelinerun down locally
"What will it create?"read the YAML--dry-run resolves it

Where OrchStep stops

OrchStep computes the names and orders the steps; it does not provision infrastructure. The echos stand in for your real kubectl create namespace, Helm install, and DNS calls — and for the teardown, your kubectl delete. If your platform already gives you preview environments as a managed feature (Vercel, Netlify, Heroku review apps) and you live inside it, use that; it's less to own. This is for the teams running their own clusters who need the create/destroy logic to be readable, runnable locally, and impossible to forget the teardown for.

Where to go next

Runnable as-is — every step only echos. Try orchstep run up --var pr=482 and then orchstep run down --var pr=482 to see the symmetric pair.

#PREVIEW-ENVIRONMENTS#PULL-REQUESTS#PLATFORM#EPHEMERAL
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD