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.
blog/preview-environmentsPreview 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:
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-482namespace, the deploy target, thepr-482.preview.acme.devhostname — is computed fromvars.pr. There is no second source of truth to drift out of sync. - The namespace is computed once. The
namespacestep emitspr-<number>and captures it assteps.namespace.ns, so every later step references the same derived value instead of re-deriving it (and re-deriving it slightly differently). upanddownare 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-runThe 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
| Concern | Two CI jobs + glue | OrchStep |
|---|---|---|
| Inputs | scattered env vars | one --var pr= |
| Namespace / URL naming | re-derived per step | computed once, reused |
| Teardown | a separate, fragile job | a symmetric down task |
| Debugging a stuck teardown | re-run the pipeline | run 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
- Variables & Outputs — deriving and reusing computed values
- Agents & CI Overview — running the same workflow in CI and locally
- Previewing with Dry Run — resolve the plan, provision nothing
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.
curl -fsSL https://orchstep.dev/install.sh | sh