Ephemeral Environments and Test Infrastructure
Test infrastructure is a product with users. Environments created per branch and destroyed after, seeding them quickly, and why a shared staging box becomes a queue.
Test infrastructure is a product with users. Environments created per branch and destroyed after, seeding them quickly, and why a shared staging box becomes a queue.
One shared staging environment, six teams. Someone is always mid-test, someone else needs it for a demo, and a third person has just deployed a broken branch to it. The environment becomes a queue, and the queue is invisible in every metric anybody reports.
The alternative is environments created on demand and destroyed after. By the end of this lesson you will know what makes that feasible, why seeding is the part that decides whether it works, and how to think about test infrastructure as a product with users rather than as a chore.
The failure is structural rather than a matter of discipline. A single long-lived environment has properties that get worse as more people use it:
Contention
One team at a time can trust its state, so everyone else is queueing without a queue to point at.
Drift
Hand-fixed over months, so it resembles nothing — neither production nor a clean install.
Unclear state
Is that data seeded, left over, or a bug? Nobody knows, and nobody dares delete it.
No isolation, and one broken deploy blocks all
Two teams' test data interact in undocumented ways, and a bad deployment stops everybody.
Irreproducible
"It works in staging" is unfalsifiable, because staging is unique. A bug that only appears there cannot be attributed — and a bug that does not appear there proves nothing.
An ephemeral environment is created for one purpose — a pull request, a test run, a demo — and destroyed when that purpose ends.
What they buy is worth listing because each addresses one row of the table above: no contention, no drift, reproducibility by construction, no blocking, and full isolation.
The properties that make one work:
The word shape is deliberate. An ephemeral environment cannot have production's data volume or instance count, and that is fine for correctness testing. It must have the same topology: the same services, the same database engine and version, the same configuration mechanism. Otherwise it answers a different question from the one you asked.
The whole thing hangs off two ideas from earlier in this track: containerisation makes the stack reproducible, and seeding makes it useful.
Managed platforms do much of this: Vercel and Netlify give preview deployments for front ends automatically, Render and Railway do it for services, and Kubernetes plus Helm or Kustomize is the do-it-yourself version.
An empty environment is nearly useless: no accounts to sign in with, no data to list, nothing to exercise. Seeding is what makes it a place you can work, and it is the part that decides whether the whole idea succeeds.
Three approaches, and the trade-offs mirror the test-data lesson:
Two properties make any of them work.
Idempotent. Running it twice gives the same state — no duplicate keys, no accumulation. That is what lets a broken environment be reset rather than repaired.
Fast. Under a few minutes, or people stop creating environments. If seeding is slow, the answer is usually a pre-built snapshot restored rather than a script replayed — a database dump created once by the script.
Prefer seeding through the application's own interfaces — the API, or the service layer — rather than by direct database inserts. Then the seed cannot create states the application could not, it exercises validation, and it stays correct when the schema changes because it goes through the same code path production does.
Bad — infrastructure as a chore, owned by nobody:
Good — infrastructure with an owner, a contract and a way to report problems:
The bad version is not unusual and it has a specific cost: every engineer pays a tax in waiting, in guessing, and in interrupting whoever set it up. Because the cost is spread across everyone it never becomes anyone's priority.
The good version treats the people using it as users. It states what they get, how long it takes, how to reset it, who owns it and where to complain — and having a target makes degradation visible rather than gradual. That framing is what gets the work funded.
Ephemeral environments do not remove the need for a stable one, and pretending otherwise causes its own problems.
So the arrangement that works is both: ephemeral environments per pull request for correctness and review, and one or two long-lived environments — production-shaped, rebuilt from code, with realistic data — for the questions that need scale and time.
The important difference from the shared-staging failure is that the long-lived environment is now rebuilt from the repository rather than maintained by hand. It can be destroyed and recreated, which is what stops it drifting into something unique.
The next lesson goes to the one environment that cannot be replicated: production. Feature flags, canaries, shadow traffic, synthetic journeys and a rollback you have actually rehearsed — the things that are only true where the real users are.
Before that, time how long it takes to get a working environment for the project you work on, from nothing. If the answer is more than an hour or involves asking somebody, that number is the argument for this lesson, and it is worth writing down.
per pull request the branch deployed, with its own database, its
own URL, seeded from scratch. Destroyed on merge.
per test run a database and services in containers, created and
torn down by the test harness
per developer an isolated stack a person can break freely[ ] created by automation, from the repository — never by hand
[ ] a unique, discoverable URL per environment
[ ] seeded with everything needed, in under a few minutes
[ ] fully destroyed afterwards — including the database and any
cloud resources it created
[ ] cheap enough that nobody hesitates to make one
[ ] identical in shape to production, if not in scaleFROM CODE a script that creates a minimal, known set through
the application's own API or service layer
reproducible, committed, safe, fast to write
small; misses volume-related behaviour
-> the default
FROM A SNAPSHOT restore an anonymised copy of production
real volume and messiness, finds slow queries
slow to restore; must be anonymised correctly;
needs re-anonymising as the schema changes
GENERATED a script producing realistic data at realistic
AT SCALE scale, with plausible distributions
no leak risk, committable, tunable
the most work up front"The staging environment is broken again. Ask Sam, they set it up
originally. The seed script is in someone's home directory and
needs a manual step nobody has written down."docs/environments.md
Preview environments are created for every pull request and
destroyed on close. URL is commented on the PR within 6 minutes.
Seeded with: 2 accounts (admin@preview / user@preview), 50
contacts, one completed order.
To reset one: comment "/reset-preview" on the pull request.
To get a larger dataset: comment "/seed realistic".
Owner: platform team. Problems: #platform-support.
Uptime target: a preview available within 10 minutes of opening
a PR, 95% of the time.performance and load a small preview environment cannot answer a
capacity question. Load testing needs a
production-shaped environment.
soak tests hours of running; nothing ephemeral survives
integration with a third party's sandbox often allows one
third parties registered callback URL, not forty
release candidate one place where the exact thing about to ship
verification is exercised
exploratory testing with realistic data volumes and history
demos and training a stable URL and stable data# Why one shared staging degrades — structurally, not by indiscipline
contention / drift / unclear state / blocking / no isolation /
irreproducible ("it works in staging" becomes unfalsifiable)
# the cost is a queue that appears in no metric
# Ephemeral: created for a purpose, destroyed when it ends
per pull request / per test run / per developer
# What makes one work
created by automation from the repository — never by hand
a unique, discoverable URL
seeded in under a few minutes
FULLY destroyed after — database and cloud resources included
cheap enough that nobody hesitates
the same SHAPE as production: same services, engine, config
mechanism. Not the same scale, and that is fine.
# Teardown is the half people forget
fire it on pull-request close
a scheduled sweep for orphans older than a few days
tag every cloud resource with the PR that owns it
# forty surviving namespaces is a bill nobody notices
# Seeding decides whether the idea succeeds
from code minimal known set via the app's own API <- default
from a snapshot anonymised production: real volume and mess
generated realistic data at scale, no leak risk, most work
two requirements
IDEMPOTENT running twice gives the same state — so a broken
environment is reset, not repaired
FAST under a few minutes, or people stop creating them
(restore a pre-built dump rather than replaying a script)
seed THROUGH the application's interfaces, not direct inserts
# then it cannot create impossible states, and it survives schema
# changes
# Treat it as a product with users
state what they get / how long it takes / how to reset it /
who owns it / where to report problems / a target, so degradation
is visible
# Still needs a long-lived environment
performance and load / soak tests / third-party sandboxes with one
callback URL / release-candidate verification / exploratory testing
with real volumes / demos
# but REBUILT FROM CODE, not maintained by hand — that is the
# difference from the shared-staging failure# a per-pull-request environment, sketched
name: preview
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push the image
run: |
docker build -t "$REGISTRY/app:$GIT_SHA" .
docker push "$REGISTRY/app:$GIT_SHA"
- name: Create an isolated namespace and database
run: |
NS="pr-${{ github.event.number }}"
kubectl create namespace "$NS" --dry-run=client -o yaml \
| kubectl apply -f -
./scripts/create-database.sh "$NS"
- name: Deploy and migrate
run: |
NS="pr-${{ github.event.number }}"
helm upgrade --install app ./charts/app \
--namespace "$NS" \
--set image.tag="$GIT_SHA" \
--set ingress.host="pr-${{ github.event.number }}.preview.test" \
--wait --timeout 5m
kubectl -n "$NS" exec deploy/app -- alembic upgrade head
- name: Seed
run: |
NS="pr-${{ github.event.number }}"
kubectl -n "$NS" exec deploy/app -- python -m app.seeds.preview
- name: Comment the URL on the pull request
run: gh pr comment ${{ github.event.number }} \
--body "Preview: https://pr-${{ github.event.number }}.preview.test"# and the teardown, which is the half people forget
on:
pull_request:
types: [closed]
jobs:
destroy-preview:
runs-on: ubuntu-latest
steps:
- run: |
NS="pr-${{ github.event.number }}"
helm uninstall app --namespace "$NS" || true
./scripts/drop-database.sh "$NS"
kubectl delete namespace "$NS" --ignore-not-found# the pattern worth aiming for
./scripts/seed.sh --minimal # 30 seconds: accounts + a little data
./scripts/seed.sh --realistic # 3 minutes: volumes that behave
./scripts/seed.sh --from-prod # anonymised snapshot, when needed