Picture a small team working on one app. Two developers, or five; not specialists, but people who each take a feature from the database migration through the API to the screen in the mobile app, end to end, on their own branch. It’s a good way to run a team, and from the second developer onward it has one recurring failure: the message “Is anyone on staging?”
Behind that message is a developer who has finished a feature and wants a tester or a designer to see it, and can’t, because staging is running the second developer’s branch, and dev is running the third’s, which broke login two days ago and hasn’t been redeployed since. A handful of people, two shared environments, and a Slack thread doing the scheduling.
That isn’t a people problem. It’s a topology problem. Every developer is treating the environment as the place where their branch becomes real, so the environment is doing the job the merge is supposed to do, one branch at a time, and everyone else is queueing for it.
I described the production shape of my stack in My Current Stack: Django on Granian on Fly.io’s cheapest machines, Fly Postgres behind PgBouncer, Redis for the cache and the Channels layer, a separate WebSocket app, a db_worker for background jobs, Tigris for files, Firebase Auth, Cloudflare in front, and an Expo app talking to all of it. This post is how I turned that stack into something where any branch gets its own public, HTTPS-fronted copy of the whole thing a couple of minutes after its developer puts one label on the PR, with nobody else asked and nobody provisioning it, and without a second Postgres cluster, a second Redis or a second bucket. The infrastructure stays shared. What changes is that every shared resource learns to be sliced by a name, and the name comes from the branch. Nothing in it assumes a team size: it’s the same design for two developers as for twenty, and what grows is the app list, which the sweeper handles. I built it, ran three branches at once, broke it in two places I hadn’t predicted, and tore it down, all in an afternoon; the outputs below are from that run.
Why one shared environment can’t scale past one developer#
A shared dev environment has an assumption baked in: that there is one “current version of the code” that everyone wants to look at. That’s true for one developer, or for a team working a single stream of changes in lockstep. It stops being true the moment the second developer starts a feature on a different timeline, and with end-to-end ownership that’s every feature. From then on, everything the environment holds is contended:
- The deployed code. Whoever deployed last wins. Everyone else’s work is invisible until they redeploy, which undoes the first person’s.
- The database schema. This is the one that bites hardest when every developer owns their feature’s data model. The first branch adds a column. The second, deployed an hour later, doesn’t know about it and its ORM starts failing on inserts. Or both branches have a
0042_*migration, the shared database ends up in a state that matches nobody’s migration history, and someone resets it, deleting the test data the third developer spent an afternoon setting up. - The cache. One branch’s serialiser change is cached under the same Redis keys another branch’s code reads. The bug reports that produces are baffling and cost a morning each.
- Third-party integrations. One OAuth callback, one webhook URL, one push certificate. Whoever’s branch is deployed gets the events.
- The tester. They can only test what’s deployed, so they test one feature at a time in the order the environment happened to be claimed, not the order the features are ready.
The team responds with a booking system: a Slack thread, a pinned message, a bot with /claim staging. That’s a lock, and a lock on the only place your work can be seen is a lock on delivery rate. With two developers it’s an irritation. With three it’s tolerable on a good week. Add a fourth, or have one feature hold staging for three days of back-and-forth with a designer, and it stops being tolerable.
The first fix people reach for is a third shared environment, “dev2” or “uat”. That buys a few weeks. The queue just has two lanes.
The real fix is to notice that dev and staging were never environments. They were the current state of someone’s branch, with a URL. If that’s what they are, the right number of them is the number of branches the team cares about right now, and the right lifetime is the lifetime of the branch.
The model: the branch is the environment#
Here’s the target state.
| Environment | How many | Created by | Destroyed by | Lives for |
|---|---|---|---|---|
| Branch environment (was “dev”) | One per active branch | An env label on the branch’s PR, then every push; by hand, the same ./scripts/env up for a spike without a PR | Branch deletion, plus a nightly sweeper | Days |
| Staging | One per release candidate | CI when a tag is cut from main | CI once the same image is promoted to prod | Hours |
| Prod | One | You, once | Nobody | Forever |
Read the Created by and Destroyed by columns again: every entry is a git event or a clock. Git is the control plane. The set of environments that should exist is a function of the set of branches that exist, and the automation’s only job is to keep reality matching it: a label on the PR creates, every push after that updates, closing or merging the PR destroys, and a nightly job reconciles whatever the first three missed. Nor does every branch get one: the label is the gate, one click by the developer, so a backup push or a bot’s dependency bump costs nothing. Nobody provisions an environment. Not a DevOps engineer with a ticket, not a bot you ask in Slack, not an AI agent you prompt each time. The tenth developer’s first branch gets an environment the same way the first developer’s did, by labelling its PR, and if a person has to be asked, it hasn’t scaled; the queue has just moved.
And the rule that makes it work: every resource an environment touches is named after the branch, deterministically. Not after the developer, not after a ticket, not after a PR number that doesn’t exist until someone opens one. After the branch, because the branch is what developers actually work with, and because a deterministic name means the second run of ./scripts/env up on the same branch finds the environment it already made rather than making another.
The name is a slug for humans and a short hash for uniqueness:
branch=$(git rev-parse --abbrev-ref HEAD) # feature/payments-retry
slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20)
hash=$(printf '%s' "$branch" | sha256sum | cut -c1-6)
ENV="${slug%-}-${hash}" # feature-payments-retr-3f9a1cfeature-payments-retr-3f9a1c is the environment. That string becomes the Fly app name, the Postgres database name (with hyphens turned to underscores), the Redis key prefix, the Tigris object prefix, the Sentry environment, and the left-hand label of the hostname. Anyone who knows the branch name can compute it. Nothing has to be looked up.
That last sentence is the design. There is no registry of environments and no coordinator. env up, env down, the Cloudflare Worker that routes hostnames and the nightly sweeper each recompute the name from the branch independently and agree by construction. Cloudflare in particular is not coordinating anything: the Worker is a pure function from hostname to Fly app, and it never learns that an environment was created or destroyed. If the app exists the request lands; if it doesn’t, Fly answers with its own error.
Three things fall out of this.
First, “who’s on staging” stops being a question. There is feature-payments-retr-3f9a1c-dev.marucommunity.com and fix-login-redirect-8b21e0-dev.marucommunity.com, and each belongs to whoever is on that branch.
Second, staging becomes an honest rehearsal of prod rather than a dumping ground. It’s built from the exact image that will go to prod, against a database cloned from a prod-shaped snapshot minutes earlier. If it works there, the only variable left in prod is prod’s data.
Third, each developer gets power without getting root on anything shared. The blast radius of a branch environment is that branch environment. A fourth hire on their first day can deploy whatever they like to their branch, and the worst outcome is that their branch is broken.

koreapost rows underneath.Shared physically, separated logically#
The tempting version of “an environment per branch” is a full copy: a Postgres cluster each, a Redis each, a bucket each. It’s clean and it’s the wrong trade. A Fly Postgres cluster is a machine plus a volume plus a restore; a Redis is another machine; you’d be waiting minutes for each environment and paying for a dozen idle databases. The sweeper would have four kinds of thing to clean up instead of one.
Instead, each shared service is provisioned once for the whole preview org, sized like a small prod, and every environment gets a slice of it keyed by $ENV. This is the table I’d want on the wall:
| Resource | Shared thing | What’s per-environment | Enforced by |
|---|---|---|---|
| Compute | Fly org maru-preview | One Fly app, maru-$ENV, one machine | Fly: apps are the isolation unit |
| Secrets | Fly’s per-app secret store | The app’s own secrets; SECRET_KEY, FIELD_KEY and ENV_TOKEN are generated per environment, not copied | Fly: secrets belong to an app, and a machine can only read its own |
| Postgres | One Fly Postgres cluster maru-preview-pg | One database, env_feature_payments_retr_3f9a1c, cloned from seed_template | Postgres: a database is a hard boundary, no cross-database queries |
| Redis | One Upstash/Fly Redis | Key prefix feature-payments-retr-3f9a1c: on the cache and the Channels layer | Django KEY_PREFIX, channels_redis prefix |
| Files | One Tigris bucket maru-preview | Object prefix feature-payments-retr-3f9a1c/ | django-storages location; presigned URLs already scope to the key |
| Hostname | The zone’s existing proxied *.marucommunity.com record | The left label, <env>-dev | A Cloudflare Worker route on *-dev.marucommunity.com/* |
| Auth | One Firebase project maru-dev | Nothing; user identity is shared on purpose (see below) | Authorised domain marucommunity.com |
| Errors and logs | One Sentry project, Fly’s logs | environment=$ENV tag on everything | Settings |
Two of those need a word.
Postgres: database, not schema. The other way to slice one cluster is a schema per environment, with search_path set per connection. Django will do it if you push a -c search_path=... through the connection options, but PgBouncer in transaction-pooling mode discards startup options unless you tell it not to, and then every connection in the pool has to belong to one environment anyway. It’s a source of “why is my query hitting the wrong tables” bugs that a database per environment simply doesn’t have. A database is a hard boundary, CREATE DATABASE ... TEMPLATE makes one in seconds, and DROP DATABASE makes it go away with nothing left behind. Branch environments connect to Postgres directly on port 5433 rather than through PgBouncer, the same way the db_worker does in prod; the pooler earns its keep at production concurrency, which a branch environment never sees.
Secrets: generated, not shared. It’d be easy to have one set of dev secrets and copy it into every environment. Don’t. The env up script generates a fresh SECRET_KEY (session signing), FIELD_KEY (anything encrypted at rest) and ENV_TOKEN (the bearer token the mobile app presents, more on that below) for each environment and stores them only in that Fly app’s secret store. It means a session cookie from one branch is worthless on another, a leaked preview token opens one preview, and the developer never sees the values unless they go looking. If you keep secrets in something like 1Password or Doppler rather than only in Fly, the same rule applies: the path is maru/preview/$ENV/, and the sweeper deletes the path with the environment. The only secrets that are shared across environments are the credentials for the shared infrastructure itself (the Postgres admin URL, the Redis URL, the Tigris key), and those live in CI, not in any environment.
The Redis row is the easiest to doubt, so here is the test. Set a cache key from inside one environment, read it from the other, then list every key in the shared Redis:
$ fly ssh console -a maru-feature-profile-tagl-fa8832 -C "python manage.py shell -c \"from django.core.cache import cache; cache.set('who-am-i','tagline-branch',600)\""
$ fly ssh console -a maru-feature-profile-webs-dcb2e7 -C "python manage.py shell -c \"from django.core.cache import cache; print(cache.get('who-am-i'))\""
None
$ # every key in the shared Redis, scanned from the website machine
feature-profile-tagl-fa8832:1:who-am-i
release:89112c88f7da008e:done
...Same Redis, same key name, the other environment sees nothing. (The release:* keys in that listing are the release-gate bug I mentioned above, caught by this exact scan; they’re under the prefix now.)
Everything else in this post is the script that stamps $ENV onto those eight rows, and the rules that keep it honest.
./scripts/env up#
This is the whole thing, as a script a developer runs from their branch. CI runs the same script; there’s no separate CI path to drift. It needs fly and git on the laptop and nothing else: SQL goes to the Postgres app over fly ssh, and the environment’s Redis keys and Tigris objects are removed by the environment’s own machine before it’s destroyed, so the laptop never holds the shared Redis or bucket credentials beyond a gitignored .env.preview.
#!/usr/bin/env bash
# scripts/env up|down|name|url — one environment per branch on shared preview infrastructure
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; } # shared-infra credentials; CI secrets in Actions
ORG=${PREVIEW_ORG:-korea-post}
PREVIEW_PG_APP=${PREVIEW_PG_APP:-maru-preview-pg}
BASE_DOMAIN=${BASE_DOMAIN:-marucommunity.com}
branch=${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}
[[ "$branch" == "main" ]] && { echo "main has no branch environment; it has staging" >&2; exit 1; }
slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20); slug=${slug%-}
hash=$(printf '%s' "$branch" | shasum -a 256 | cut -c1-6)
ENV="${slug}-${hash}"
[[ -n "${ENV_OVERRIDE:-}" ]] && ENV="$ENV_OVERRIDE" # the sweeper only knows the name
APP="maru-${ENV}"
DB="env_${ENV//-/_}"
HOST="${ENV}-dev.${BASE_DOMAIN}"
pg() { fly ssh console -a "$PREVIEW_PG_APP" -q -C "psql postgres://postgres:${PREVIEW_PG_PASSWORD}@localhost:5433/postgres -At -c \"$1\""; }
up() {
echo "== $branch -> $ENV"
# 1. Compute. Idempotent: an app that exists is left alone.
fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\"" || fly apps create "$APP" --org "$ORG"
# 2. Database: clone the seed template unless this branch already has one.
if [[ "$(pg "SELECT 1 FROM pg_database WHERE datname='$DB'")" != "1" ]]; then
pg "CREATE DATABASE $DB TEMPLATE seed_template"
fi
DATABASE_URL="postgres://postgres:${PREVIEW_PG_PASSWORD}@${PREVIEW_PG_APP}.flycast:5432/${DB}"
# 3. Secrets. Generated ones are generated once; a later `up` must not rotate them.
existing=$(fly secrets list -a "$APP" --json 2>/dev/null | grep -o '"Name": *"[^"]*"' | cut -d'"' -f4 || true)
gen() { grep -qx "$1" <<<"$existing" || printf '%s=%s\n' "$1" "$(openssl rand -hex 32)"; }
{
gen SECRET_KEY
gen ENV_TOKEN
cat <<S
ENV_NAME=$ENV
ALLOWED_HOSTS=$HOST,$APP.fly.dev
CSRF_TRUSTED_ORIGINS=https://$HOST,https://$APP.fly.dev
DATABASE_URL=$DATABASE_URL
REDIS_URL=$PREVIEW_REDIS_URL
BUCKET_NAME=$PREVIEW_BUCKET
AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev
AWS_ACCESS_KEY_ID=$PREVIEW_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$PREVIEW_AWS_SECRET_ACCESS_KEY
WS_URL=wss://$HOST
S
} | fly secrets import -a "$APP" --stage
# 4. Deploy the working tree, stamped with what it is.
fly deploy -a "$APP" --config fly.preview.toml --remote-only --ha=false \
--build-arg GIT_SHA="$(git rev-parse --short HEAD)" --build-arg GIT_BRANCH="$branch" \
--image-label "$(git rev-parse --short HEAD)"
echo "https://$HOST"
}
down() {
[[ "$APP" =~ ^maru-[a-z0-9-]+-[0-9a-f]{6}$ ]] || { echo "refusing to destroy $APP" >&2; exit 1; }
if fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\""; then
fly machine start -a "$APP" >/dev/null 2>&1 || true
fly ssh console -a "$APP" -q -C "python manage.py env_teardown" || echo "warning: self-teardown failed; the sweeper will catch stragglers" >&2
fly apps destroy "$APP" --yes
fi
pg "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB'" >/dev/null || true
pg "DROP DATABASE IF EXISTS $DB" || true
}
case "${1:-}" in up) up ;; down) down ;; name) echo "$ENV" ;; url) echo "https://$HOST" ;; esacFor the demo I ran it from my laptop rather than pushing, because the three branches were throwaways I didn’t want in the repository’s history and I wanted to watch the output as it went. In the team setup nobody types this: the label workflow further down runs exactly this script, and what follows is what would land in the Actions log. Here is what it printed the first time, on the branch that carries the tooling itself:
$ ./scripts/env up
== feature/branch-environments -> feature-branch-envir-c65cb4
New app created: maru-feature-branch-envir-c65cb4
creating database env_feature_branch_envir_c65cb4 from seed_template
CREATE DATABASE
Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
==> Building image with Depot
image: registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9
image size: 139 MB
> Machine 865529aee76918 [app] was created
✔ Machine 865529aee76918 [app] update finished: success
https://feature-branch-envir-c65cb4-dev.marucommunity.com
$ curl -s https://feature-branch-envir-c65cb4-dev.marucommunity.com/api/version/
{"environment": "feature-branch-envir-c65cb4", "branch": "feature/branch-environments", "commit": "87c061b9",
"image": "registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9", "app": "maru-feature-branch-envir-c65cb4", "region": "syd"}About two and a half minutes from command to URL, most of it the image build. The second up on the same branch, after another commit, printed no “New app created” and no “creating database”, staged the same secret names without regenerating the two generated ones, and rebuilt: same URL, new commit.
Some of the choices in there deserve a sentence each.
fly.preview.toml is not fly.toml. Prod runs the API, the WebSocket server and the db_worker as separate Fly apps and process groups so each scales on its own metric, which is the whole argument of the stack post. A branch environment doesn’t need to scale; it needs to be one machine. So the preview config has a single process, and entrypoint.sh grows a granian-preview mode that runs the release gate and migrations and then starts one Granian under ASGI with --ws. The ProtocolTypeRouter in asgi.py already routes both http and websocket, so one process serves the whole app. Sync views run thread_sensitive in that mode, roughly one request at a time, which is exactly the trade I moved away from in prod and exactly right here, because nobody testing a branch will notice.
# fly.preview.toml (the app name is supplied by `fly deploy --app`)
app = 'branch-environment-placeholder'
primary_region = 'syd'
[processes]
app = "granian-preview"
[env]
DJANGO_SETTINGS_MODULE = 'koreapost_project.settings'
DEBUG = 'False'
WEB_BUNDLE_FROM_BUCKET = '0' # the image's own web bundle; the preview bucket never holds one
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = 'suspend'
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
path = '/api/health/'
[http_service.checks.headers]
Host = '127.0.0.1' # base.py allows loopback; the app's own hostname isn't known to a static file
[[vm]]
size = 'shared-cpu-1x'
memory = '1gb'auto_stop_machines = "suspend", min_machines_running = 0. A branch environment nobody has opened since lunch should cost nothing. Suspend rather than stop gives the ~3 second resume I ended up with after chasing 504s instead of a 30-second cold start, and here even 30 seconds would be tolerable. Two of the three demo environments were already suspended in fly apps list a minute after their deploy finished.

DEBUG = False, same settings module as prod. A branch environment is a public URL. It should behave like prod in every way that matters for testing (real HTTPS, real cookies, the same health check) and unlike prod in every way that could hurt someone. Rather than a separate preview.py, the difference is carried by the secrets env up sets: sandbox payment keys, no Firebase admin credentials, and the ENV_NAME that switches on every prefix below.
One setting slices everything. ENV_NAME is the only new knob in settings/base.py, and it’s applied in four places:
ENV_NAME = os.getenv("ENV_NAME", "")
_ENV_PREFIX = f"{ENV_NAME}/" if ENV_NAME else ""
CACHES["default"]["KEY_PREFIX"] = ENV_NAME # every cache key, and the list-cache version counters
CHANNEL_LAYERS["default"]["CONFIG"]["prefix"] = f"{ENV_NAME}:asgi" # group and channel names
STORAGES["default"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}media" # uploads
STORAGES["staticfiles"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}static"Miss the channels one and two branches’ WebSocket servers share group names, and a message sent in one environment turns up in another. That is the kind of bug that costs a day.
There was a fifth place I only found by reading the shared Redis after the fact. The release gate from the 504s post, the thing that lets one machine per image run migrations while the others wait, keys its lock on a hash of the image reference. Two branch environments built from the same commit have the same image. The second one would have found the first’s “done” marker and skipped its own migrations against its own, un-migrated database. Now the environment name is part of the hashed value and the keys live under the prefix. It’s the sort of thing a shared-infrastructure design keeps producing: anything that was implicitly “per deployment” has to be made explicitly per environment, and you don’t know what was implicit until you look.
The database is cloned, not migrated from empty. That’s the next section.
The database: a template, cloned in seconds#
A branch environment’s database can be one of three things. Shared with everyone, which is what got us here. Empty, migrated from zero and seeded with fixtures: deterministic, but fixtures are never as good as real-shaped data and are always eighteen months stale. Or a clone of a prod-shaped snapshot: realistic row counts, realistic nulls in columns the fixtures assumed were always filled, the migration that’s instant on an empty table and takes forty minutes on a real one shows itself.
The third, with the second kept as a CI-only check. And what makes the third fast enough to do per branch is a Postgres feature most people forget exists:
-- Nightly, on maru-preview-pg. Restore last night's prod backup into seed_raw,
-- run the anonymiser, then freeze it as a template nobody can connect to.
ALTER DATABASE seed_raw RENAME TO seed_template;
UPDATE pg_database SET datistemplate = true, datallowconn = false WHERE datname = 'seed_template';
-- Per branch, in the time it takes to copy the files:
CREATE DATABASE env_feature_payments_retr_3f9a1c TEMPLATE seed_template;CREATE DATABASE ... TEMPLATE is a file-level copy within the cluster. A few gigabytes is seconds, not the minutes a pg_restore takes. The template can’t have open connections while it’s being cloned, which is what datallowconn = false enforces, and it’s also why the nightly job builds into seed_raw and renames at the end rather than restoring over the live template.
For the demo the template was built by hand rather than from a prod dump: a tunnel to the preview cluster with fly proxy, manage.py migrate, then the repo’s own seed commands for taxonomies, a buyer and seller with a conversation, and twenty community posts. Nineteen megabytes. Three clones of it appeared in the cluster as three branches came up, each 19 MB, each taking under a second:
$ fly ssh console -a maru-preview-pg -C "psql ... -c '\l'"
datname | template | size
---------------------------------+----------+-------
env_feature_branch_envir_c65cb4 | f | 19 MB
env_feature_profile_tagl_fa8832 | f | 19 MB
env_feature_profile_webs_dcb2e7 | f | 19 MB
seed_template | t | 19 MBThe preview cluster is one unmanaged Fly Postgres node, shared-cpu-1x with a 1 GB volume, about two dollars a month. The branch environments connect to it over .flycast on the private network; nothing about it is public.
The anonymiser is not optional. The moment prod data is reachable on a -dev hostname, it needs to not be prod data: names, emails, phone numbers, addresses, free text, payment references, tokens, all overwritten before the rename. If the anonymiser fails, the previous night’s template stays and someone gets a message. It’s a small amount of work and it’s the thing that makes the whole approach acceptable to whoever answers your privacy questionnaire.
The release gate in entrypoint.sh runs migrate on the first boot of every image, so the branch’s own migrations apply on top of the template. That’s the first honest test of “does my migration work on data shaped like prod”, and it happens before anyone looks at the URL.
Hostnames: a wildcard, a Worker, and two things the docs didn’t tell me#
Every environment needs a public HTTPS hostname, and “public” is the point: a tester on a phone, a designer on another network, a product manager who won’t install a VPN. Fly gives each app maru-$ENV.fly.dev for free, and that alone works. But *.fly.dev hostnames have three problems: they’re ugly to send around, Cloudflare isn’t in front of them so none of the protections you have on prod apply, and if you want them on your own domain you’re doing a DNS record and a certificate per environment, which is one more thing for the sweeper.
The version that removes all of that is one wildcard and one Cloudflare Worker. I’d planned *.dev.marucommunity.com. Two things stopped that, and both are worth knowing before you plan the same.
Universal SSL covers one wildcard level. Cloudflare’s free certificate is issued for marucommunity.com and *.marucommunity.com. It does not cover *.dev.marucommunity.com; a second-level wildcard needs Advanced Certificate Manager at $10 a month. The first request to feature-branch-envir-c65cb4.dev.marucommunity.com died with an SSL handshake failure before anything of mine ran. The fix is to keep the environment in the first label: feature-branch-envir-c65cb4-dev.marucommunity.com. That’s covered by the certificate that already exists, and by the proxied *.marucommunity.com record that already exists, the one I set up as a sinkhole for the scanners walking invented subdomains. So an environment needs no DNS at all.
Redirect Rules run before Workers. With the route in place, every -dev hostname answered 301 to the www homepage without the Worker running. The sinkhole is a zone-level redirect rule, “any other subdomain → www”, and Cloudflare’s redirect phase executes before the Workers phase. The rule needed one clause: and not ends_with(http.host, "-dev.marucommunity.com"). The deploy script adds it on up and removes it on down, because a rule someone edited by hand is the thing that gets forgotten.

-dev.marucommunity.com, which fall through to the Worker.The DNS side is the record that was already there:

With those two out of the way the routing is a single Worker on *-dev.marucommunity.com/*:
// infra/dev-router.js — <env>-dev.marucommunity.com -> maru-<env>.fly.dev
const DEV_SUFFIX = "-dev.marucommunity.com";
const ENV_NAME = /^[a-z0-9-]+-[0-9a-f]{6}$/; // slug-hash, as scripts/env names them
export default {
async fetch(request) {
const url = new URL(request.url);
if (!url.hostname.endsWith(DEV_SUFFIX)) return new Response("not a dev hostname", { status: 404 });
const env = url.hostname.slice(0, -DEV_SUFFIX.length);
if (!ENV_NAME.test(env)) return new Response(`no such environment: ${env}`, { status: 404 });
url.hostname = `maru-${env}.fly.dev`;
const upstream = new Request(url, request);
upstream.headers.set("X-Forwarded-Host", request.headers.get("host") ?? "");
const res = await fetch(upstream);
const out = new Response(res.body, res);
out.headers.set("X-Robots-Tag", "noindex, nofollow"); // a half-finished branch is not for search engines
out.headers.set("X-Dev-Environment", env);
return out;
},
};Cloudflare terminates TLS on the wildcard, the Worker computes the Fly hostname from the label, and Fly’s own *.fly.dev certificate covers the hop to the machine. WebSocket upgrades pass through fetch unchanged. And because the route is on the zone, everything Cloudflare does for prod is available: WAF rules, rate limits, bot fight mode, and Access.

Three branches up at once, one Worker, and what each hostname answered:
| Hostname | HTTP | Routed to | branch | commit |
|---|---|---|---|---|
feature-branch-envir-c65cb4-dev.marucommunity.com | 200 | maru-feature-branch-envir-c65cb4.fly.dev | feature/branch-environments | 6560ec9a |
feature-profile-tagl-fa8832-dev.marucommunity.com | 200 | maru-feature-profile-tagl-fa8832.fly.dev | feature/profile-tagline | e3baa936 |
feature-profile-webs-dcb2e7-dev.marucommunity.com | 200 | maru-feature-profile-webs-dcb2e7.fly.dev | feature/profile-website | beb08161 |
not-an-env-dev.marucommunity.com | 404 | nothing; the Worker refuses the label |
Every 200 came back with X-Robots-Tag: noindex, nofollow and X-Dev-Environment: <env> added by the Worker. The 404 is the Worker’s own, before Fly is ever asked.
Cloudflare Access is what turns “public” into “reachable by the people who should reach it”. One application on *-dev.marucommunity.com with a policy of “anyone with an @marucommunity.com email” and browser visitors get a login page once and never think about it again. That covers designers, PMs and testers on laptops. I didn’t add it for the demo; the environments were up for an hour with noindex and nothing on them but seed data.
It doesn’t cover the mobile app, because a native app can’t complete an Access login redirect. Two ways through. Access supports service tokens (a CF-Access-Client-Id / CF-Access-Client-Secret header pair) that bypass the login, and the Expo app’s dev menu can carry a pair for the dev hostnames. Or, simpler, add an Access bypass for /api/ and have Django reject any request without either a Firebase user or the environment’s ENV_TOKEN, the one env up generated for exactly this environment. Either way, the Expo side is a dev-menu screen with two fields, base URL and token, and the app a tester already has installed is now talking to that branch. No new build. The OTA update channel stays pointed at whatever the app was built with; the API base URL is a runtime setting, not a build-time one, and it should have been anyway.

Firebase Auth is the one shared service that’s shared on purpose. Identity isn’t environment-specific; a tester wants to log in with the same account on every branch. One dev Firebase project with marucommunity.com in its authorised domains covers every -dev hostname, and every environment verifies ID tokens against it. The users table in each environment’s database is a copy of the template’s users, so the tester’s account exists everywhere the template did.
Automating it on GitHub: events in, environments out#
Everything above by hand is fine when you’re the only one doing it. The moment there’s a second developer it has to happen because something happened in git, not because someone remembered. The whole automation is four workflows, one repository setting, one ruleset and one GitHub environment, and every one of them maps a git event to a call of the same scripts/env the developer runs by hand. There is no CI-only path to drift.
| Git event | What runs | What it does |
|---|---|---|
PR gets the env label | branch-env.yml → scripts/env up | creates that branch’s environment, posts the URL on the PR |
| push to a labelled PR’s branch | branch-env.yml → scripts/env up | updates it in place |
| label removed, PR closed or merged | branch-env.yml → scripts/env down | the environment cleans up its slice and is destroyed |
| branch deleted | branch-env.yml → scripts/env down | backstop for a branch brought up by hand that never had a PR; the repo setting delete branch on merge makes merging fire it too |
| push to any other branch | nothing | a backup push or a spike costs nothing until someone asks to see it |
pull request to main | migrations-check.yml | one leaf in the migration graph after merging main; full chain applies from empty |
tag v* pushed | release.yml | staging environment from the tag; a reviewer’s approval promotes the same image to prod and destroys staging |
| 02:00 nightly | sweep-envs.yml → scripts/sweep-envs | destroys anything whose branch is gone or idle a week |
The label, push and delete workflow#
# .github/workflows/branch-env.yml
name: branch environment
on:
pull_request:
types: [labeled, unlabeled, synchronize, closed]
delete:
workflow_dispatch:
inputs:
branch:
description: branch to bring up without a PR
required: true
concurrency:
group: env-${{ github.head_ref || github.event.ref || inputs.branch }}
cancel-in-progress: true
env:
FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_TOKEN }} # org-scoped deploy token; a separate preview org keeps it away from prod
PREVIEW_PG_PASSWORD: ${{ secrets.PREVIEW_PG_PASSWORD }}
PREVIEW_REDIS_URL: ${{ secrets.PREVIEW_REDIS_URL }}
PREVIEW_BUCKET: ${{ vars.PREVIEW_BUCKET }}
PREVIEW_AWS_ACCESS_KEY_ID: ${{ secrets.PREVIEW_AWS_ACCESS_KEY_ID }}
PREVIEW_AWS_SECRET_ACCESS_KEY: ${{ secrets.PREVIEW_AWS_SECRET_ACCESS_KEY }}
jobs:
up:
# the gate: a PR carrying the `env` label, or someone asking for a branch by hand
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action != 'closed'
&& contains(github.event.pull_request.labels.*.name, 'env'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || inputs.branch }}
- uses: superfly/flyctl-actions/setup-flyctl@master
- id: env
env:
BRANCH: ${{ github.head_ref || inputs.branch }}
run: |
scripts/env up
echo "url=$(scripts/env url)" >> "$GITHUB_OUTPUT"
- if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: env
message: |
Branch environment: ${{ steps.env.outputs.url }}
Commit: `${{ github.event.pull_request.head.sha }}` · `/api/version/` says what it is running.
down:
# label removed, labelled PR closed or merged, or the branch itself deleted
if: >-
(github.event_name == 'pull_request' &&
((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'env')) ||
(github.event.action == 'unlabeled' && github.event.label.name == 'env'))) ||
(github.event_name == 'delete' && github.event.ref_type == 'branch')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: BRANCH="${{ github.head_ref || github.event.ref }}" scripts/env downThe gate is the env label, and it’s the part I’d argue for hardest. Without one, every branch costs an app and a database clone: the branch someone pushes to back up a laptop, the spike that gets squashed tomorrow, the dependency bump a bot opened at 3 a.m. So a push alone does nothing and opening a PR does nothing. Adding env to the PR brings the environment up, every push while the label is on updates it, and removing the label, closing the PR or merging it takes it down. Opting in is still a GitHub event, not a request to a person, and it’s one click made by the one person who knows whether anyone is going to look. The sticky comment is the user interface: label the PR, a comment appears with a URL they can send to anyone, and it updates in place on every push rather than stacking. workflow_dispatch covers the branch you want up before there’s a PR, and ./scripts/env up from a laptop covers the rest.
Two other gates are worth knowing. A branch-name filter (branches: ['feature/**', 'fix/**']) needs no PR but makes every feature branch pay. “Any PR that isn’t a draft” is the most automatic, and the right choice if your team opens PRs late and only when they’re ready. I went with the label because it puts the cost decision with the person who has the context, and because it can be taken back without closing anything. One side effect I like: pull_request runs from forks don’t get secrets, so a fork can’t bring up an environment.
concurrency matters more than it looks. A run of quick pushes to one branch cancels the earlier deploys rather than racing them; two fly deploys to the same app at once end with one of them losing.
closed covers teardown for anything that had a PR, merged or not. The delete event is the backstop for a branch brought up by workflow_dispatch that never got one, and it only fires if branches actually get deleted. That’s a repository setting, Automatically delete head branches, and it’s off by default. With it on, clicking Merge closes the PR and deletes the branch, and either event alone would have run env down. The merge button is the teardown.
gh api -X PATCH repos/jaredlynskey/koreapost -f delete_branch_on_merge=trueThe FLY_PREVIEW_TOKEN is the dangerous line in the file. It can create and destroy apps, and if $APP were ever anything but a branch environment it could destroy prod. Two guards, and I’d keep both: the token is scoped to a separate Fly org so it literally cannot see the production org (for the demo I used the production org, korea-post, to skip setting up billing on a new one, and relied on the second guard alone); and down() refuses any app name that doesn’t match the slug-hash pattern.
The check a branch environment can’t do for itself#
A branch only ever sees its own migrations, so the check that matters runs on the pull request against a merge with main, and it’s the one status check main requires:
# .github/workflows/migrations-check.yml
name: migrations
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env: { POSTGRES_PASSWORD: ci, POSTGRES_DB: ci }
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:ci@localhost:5432/ci
DEBUG: 'False'
SECRET_KEY: ci-only
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: astral-sh/setup-uv@v4
- run: uv sync --frozen
- name: Merge main into the branch (a conflict here fails the check too)
run: |
git config user.email [email protected] && git config user.name ci
git merge --no-edit origin/main
- name: One leaf in the migration graph
run: |
uv run manage.py makemigrations --check --dry-run
uv run manage.py migrate --plan > /dev/null
- name: The full chain applies from nothing
run: uv run manage.py migrate --noinputThirty seconds. It’s what turns the 0114 / 0114 collision from a release-day surprise into a red check on the second developer’s PR; the migrations section below shows it firing.
The rules on main#
None of this holds up if branches live for a month or if someone pushes to main directly. The rules are less about protection and more about keeping environments small and their migrations current:
mainis protected and always deployable. Merge via PR with themigrationscheck green against the currentmain(strict status checks, so a PR that was green last week re-runs aftermainmoves). Linear history, no force-push, no deletion.- Feature branches are short. A branch open for two weeks has an environment two weeks behind
mainand a migration two weeks more likely to collide with someone else’s. A month-long feature merges tomainbehind a flag in pieces. The flag is the long-lived thing, not the branch. - One branch, one environment, one database. Already enforced by the naming.
- Release tags are the only thing that goes to prod. The image is built once from the tag; staging and prod run that image, not a rebuild.
- A PR that touches
migrations/is reviewed by one of the other two. Below.
As a ruleset, applied once with gh api -X POST repos/…/rulesets --input infra/github-ruleset-main.json:
{
"name": "main", "target": "branch", "enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{ "type": "pull_request",
"parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true,
"required_review_thread_resolution": true } },
{ "type": "required_status_checks",
"parameters": { "strict_required_status_checks_policy": true,
"required_status_checks": [ { "context": "check" } ] } }
]
}One honest wrinkle. This repository is private on GitHub’s Free plan, and on Free, branch protection and rulesets are only available for public repositories; the API answers 403 Upgrade to GitHub Pro or make this repository public. The delete_branch_on_merge setting, the environments and the workflows all work on Free. So for a small team on a private Free repo, the ruleset above is a convention: the migrations workflow still runs and still goes red, but nothing greys out the merge button. That’s a $4-a-month decision, and I’d make it the moment a second person can merge.
There’s no develop branch and no release/* branches. Git-flow was designed around integration being expensive and needing to be batched. Branch environments make integration cheap, so the batching goes.
Staging is a tag’s environment, and prod is a promotion#
Staging reuses the whole machinery. A v* tag on main runs scripts/env up with the branch name staging/v1.42.0, which the naming turns into staging-v1-42-0-<hash>: same clone-from-template database, same suspend-when-idle machine, same hostname scheme. The difference is what comes after:
# .github/workflows/release.yml (abridged)
on:
push:
tags: ['v*']
jobs:
staging:
steps:
- run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env up
- run: echo "image=$(fly image show -a "maru-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)" --json | jq -r '…')" >> "$GITHUB_OUTPUT"
production:
needs: staging
environment: production # required reviewer on this environment = the promotion gate
steps:
- run: fly deploy --config fly.api.toml --image "${{ needs.staging.outputs.image }}"
- run: fly deploy --config fly.ws.toml --image "${{ needs.staging.outputs.image }}"
- run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env downThe production job waits on a GitHub environment with a required reviewer. Someone looks at staging, clicks Approve, and prod receives the same image staging ran, by reference, not a rebuild. The last step destroys staging because it’s done its job. If nobody approves, the release candidate was rejected, and the sweeper removes staging when its “branch” (staging/v1.42.0 never exists on origin) fails the liveness check. Nothing gets to exist without a reason the sweeper can check.
The nightly sweeper#
# .github/workflows/sweep-envs.yml
on:
schedule:
- cron: '0 14 * * *' # 02:00 NZST
workflow_dispatch:
jobs:
sweep:
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: scripts/sweep-envsfetch-depth: 0 because the sweeper needs every remote branch and its last commit date, not the one commit Actions checks out by default. workflow_dispatch so it can be run by hand after an incident.
What a developer actually does#
Push a branch, open a PR, and put the env label on it when they want someone to look. That’s the list. A URL appears on the PR within three minutes, updates on every push, and disappears when the PR is merged or closed. The migration check tells them before anyone else does if they’ve collided with a teammate. Nobody claims staging because there is no staging to claim, and nobody tears anything down because tearing down is what merging does.
And what the person who set it up does per branch, afterwards: nothing. The shared Postgres, Redis and bucket are provisioned once, the secrets go into Actions once, and from then on the recurring work is reading the sweeper’s log on a Monday. No ticket queue, no bot to ask, nobody to prompt.
Teardown that doesn’t trust the teardown#
env down is the ordinary case, and the interesting part of it is who does the cleaning. The Redis keys and the Tigris objects belong to the shared services, and the laptop running down doesn’t need credentials for either, because the environment’s own machine already has them and already knows its prefix. So down starts the machine if it’s suspended, runs manage.py env_teardown on it, and only then destroys the app and drops the database. The environment cleans up after itself:
$ BRANCH=feature/profile-website ./scripts/env down
== feature/profile-website -> feature-profile-webs-dcb2e7 (destroying)
redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
tigris: deleted 278 objects under maru-preview/feature-profile-webs-dcb2e7/
Destroyed app maru-feature-profile-webs-dcb2e7
DROP DATABASE
destroyed feature-profile-webs-dcb2e7
$ curl -s -o /dev/null -w '%{http_code}\n' https://feature-profile-webs-dcb2e7-dev.marucommunity.com/api/version/
530The 530 is the point about coordination again: the Worker still routes the hostname, Fly has nothing there, and nobody had to tell Cloudflare.
env_teardown refuses to run without ENV_NAME, because with an empty prefix it would be a wipe of the shared bucket. That guard is the whole reason the command exists as a command rather than three lines in the shell script.
Environments leak anyway. A branch is deleted while GitHub Actions is having an outage. A developer runs env up from a laptop for a spike and never pushes. A fly apps destroy fails transiently and the || true swallows it. Three months later there are forty apps and a bill that makes someone ask what happened.
So there’s a second mechanism that doesn’t trust the first one: the nightly sweeper from the table above. It lists every maru-* app, works out which branch each one belongs to, and destroys any whose branch is gone or hasn’t been pushed to in seven days. A week-idle branch doesn’t need a live URL; the next push recreates it in two minutes. A hash isn’t reversible, so the sweeper computes the environment name for every live branch and destroys any app that isn’t in that set, then drops any env_* database with no app behind it:
#!/usr/bin/env bash
# scripts/sweep-envs — nightly
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; }
ORG=${PREVIEW_ORG:-korea-post}; TTL_DAYS=${TTL_DAYS:-7}
cutoff=$(( $(date +%s) - TTL_DAYS*86400 ))
git fetch --prune --quiet origin
live=""
while IFS=$'\t' read -r ts branch; do
[[ "$branch" == "main" ]] && continue
(( ts >= cutoff )) || continue
live+="$(BRANCH="$branch" scripts/env name)"$'\n'
done < <(git for-each-ref --format='%(committerdate:unix)%09%(refname:short)' refs/remotes/origin | sed 's#^\([0-9]*\)\torigin/#\1\t#')
apps=$(fly apps list --org "$ORG" --json | grep -o '"Name": *"maru-[^"]*"' | cut -d'"' -f4)
for app in $apps; do
env="${app#maru-}"
[[ "$env" =~ ^[a-z0-9-]+-[0-9a-f]{6}$ ]] || continue # not a branch environment (e.g. maru-preview-pg)
grep -qx "$env" <<<"$live" && continue
echo "sweeping $app: branch gone or idle > ${TTL_DAYS}d"
ENV_OVERRIDE="$env" scripts/env down || true
done
# ...then any env_* database with no app: DROP DATABASE.None of the demo branches had ever been pushed (the laptop run above), so from the sweeper’s point of view all of them were gone, which is exactly the state a spike someone brought up from a laptop and never pushed would be in a week later. It took the remaining two apart, including the Redis keys the tagline environment had written:
$ ./scripts/sweep-envs
sweeping maru-feature-branch-envir-c65cb4: branch gone or idle > 7d
redis: deleted 0 keys under feature-branch-envir-c65cb4:*
tigris: deleted 322 objects under maru-preview/feature-branch-envir-c65cb4/
Destroyed app maru-feature-branch-envir-c65cb4
DROP DATABASE
sweeping maru-feature-profile-tagl-fa8832: branch gone or idle > 7d
redis: deleted 3 keys under feature-profile-tagl-fa8832:*
tigris: deleted 278 objects under maru-preview/feature-profile-tagl-fa8832/
Destroyed app maru-feature-profile-tagl-fa8832
DROP DATABASE
$ fly apps list --org korea-post | grep maru-
maru-preview-pg │ korea-post │ deployed(One small thing that bit: macOS ships bash 3, which has no associative arrays. The first version of the sweeper used one and died on declare -A on my laptop. The version above uses a newline-separated list and runs on anything.)
The cost is the pleasant part. A suspended machine costs its rootfs, which is cents a month. A database is 19 MB on a volume that’s already paid for. The Redis is Upstash’s pay-as-you-go plan, twenty cents per hundred thousand commands, which at branch-testing volumes rounds to nothing. A Tigris prefix costs what it stores. The shared Postgres node is the only fixed line, about two dollars a month. Twenty active branches would cost roughly what one always-on shared-cpu-1x costs, and the sweeper keeps it at twenty rather than two hundred. The whole demo, three environments for an hour plus the shared pieces, cost less than the coffee I drank during it.
Migrations across branches, without the shared database to warn you#
When each developer owns a feature end to end, every branch has a migration in it. That makes this section the important one.
Here’s the thing people miss when they leave the shared-environment world. When everyone deployed to one dev database, migration conflicts surfaced immediately and painfully, but they surfaced. One migration ran, the next deploy failed, someone fixed it that afternoon. With a database per branch, each migration works perfectly in its own environment, and two of them first meet when the second one merges to main. Done carelessly, this moves the conflict from dev, where it was cheap, to a release, where it isn’t.
Four rules, all enforceable by CI so nobody has to remember them.
1. CI fails on two leaves in the migration graph, against a merge with main. makemigrations --check catches models disagreeing with migrations, but the conflict that matters is two branches both adding 0042_*. Check it against the branch merged into main, not the branch alone:
git fetch origin main
git merge --no-commit --no-ff origin/main || { echo "merge conflict"; exit 1; }
python manage.py makemigrations --check --dry-run
python manage.py migrate --plan 2>&1 | grep -q "Conflicting migrations" && exit 1This is the one I made happen on purpose. Two of the three demo branches, feature/profile-tagline and feature/profile-website, each added one field to UserProfile with one migration, and each environment applied its own on the way up. The columns were only where they should be:
env_feature_branch_envir_c65cb4 pending_email
env_feature_profile_tagl_fa8832 pending_email, tagline
env_feature_profile_webs_dcb2e7 pending_email, websiteBoth migrations are 0114_*. Neither branch can see the other’s, so neither environment can tell you there’s a problem. Merging one into the other, the check does:
$ git merge --no-commit --no-ff feature/profile-tagline
$ python manage.py makemigrations --check --dry-run
CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
(0114_userprofile_tagline, 0114_userprofile_website in marketplace).
To fix them run 'python manage.py makemigrations --merge'
$ python manage.py makemigrations --merge --noinput
Created new merge migration marketplace/migrations/0115_merge_20260919_0324.py
dependencies = [
('marketplace', '0114_userprofile_tagline'),
('marketplace', '0114_userprofile_website'),
]The second author runs that --merge on their branch, in their environment, bothering nobody, and the next env up applies 0115 on top of their clone. (In this case git also flagged a textual conflict first, because both fields had been added at the same line of the model file. Realistic, and worth knowing that the textual conflict hides the migration one until it’s resolved.)
2. Migrations are backwards-compatible with the previous release’s code. During a deploy there’s a window where the new schema and the old code coexist, or the reverse if a machine rolls back. Add columns nullable or with defaults, never rename in one step, never drop what the current release still reads: add, deploy, backfill, switch code, drop in a later release. Expand and contract. The reason it matters more here is that disposable environments make deploying more frequent, so you’re inside that window more often. Staging-per-tag is where it’s rehearsed.
3. CI also migrates from zero into an empty database. The template clone tests “does my migration apply to realistic data”. The empty run tests “does the full chain still work from nothing”, which catches the migration that assumed a previous data migration had populated something. Thirty seconds, and it catches a class of bug that otherwise waits for the next new laptop.
4. Schema changes get a second pair of eyes. In a small team this doesn’t need a CODEOWNERS file, but it does need to be a rule: a PR that touches migrations/ is reviewed by a teammate before merge, and the reviewer’s job isn’t to check the SQL. It’s to know that they, or someone else on the team, are touching the same table this fortnight and say so. Past a dozen developers that knowledge stops fitting in one head, and a CODEOWNERS entry on migrations/ earns its place. No tooling replaces this. With the tooling catching everything mechanical, it’s a two-minute job, and it’s the one part of the old shared-environment world worth keeping: the moment where two people find out they’re about to collide.
Versioning, so a URL tells you what it’s running#
With one shared environment, “what’s on staging” was a Slack question. With many, it has to be a property of the environment or every bug report starts with archaeology.
- Every image carries the commit SHA and the app exposes it.
fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...stamps them into the image as environment variables, and/api/version/returns them with the environment name, the Fly app and the image reference (see the screenshot above). The first thing anyone does with “it’s broken on feature-profile-tagl-fa8832” is check it’s running the commit they think. The first demo environment was deployed from the working tree a commit before the tooling was committed, and the endpoint said so:87c061b9, not the SHA I expected. ENVIRONMENT=$ENVis in every log line and the Sentry tag. Branch errors don’t pollute prod’s error tracking and are filterable in one click.- Branches get SHAs, releases get tags.
feature-payments-retr-3f9a1cnever has a version number; it has commits. Staging and prod havev1.42.0. - The mobile app carries a minimum API version, not a URL. The dev-menu URL field is how a tester points a real phone at a branch, and the
/versionendpoint carrying an API schema version is how the app knows whether it’s talking to something it understands.
What this doesn’t solve#
It doesn’t solve two features that only make sense together. A branch environment shows one branch. The answer is to merge both behind flags and look at main, which you can do with one standing environment that tracks main (main-dev.marucommunity.com, redeployed on every merge). That’s the one long-lived non-prod environment I’d keep, and nobody deploys to it; it’s for seeing what the next release will be.
It doesn’t solve providers that want a registered callback and don’t accept wildcards. Firebase Auth does accept a parent domain, so marucommunity.com covers every -dev branch hostname. Some OAuth and payment providers don’t, and for those you keep one shared hostname that the Worker routes to whichever environment last claimed it. That’s a small lock over a small thing, and it’s a lot better than a lock over everything.
It doesn’t solve the human side of migrations. The tooling catches mechanical conflicts. It doesn’t catch two of you modelling the same concept incompatibly in the same fortnight, and the only fix for that is the migration review where one of you notices.
What I’d check in your setup#
If you have a shared dev or staging and a Slack thread for booking it:
- How long does a finished branch wait before someone other than its author can see it running? More than an hour and you’re paying for the lock in delivery time, and on a small team that’s a big fraction of it waiting.
- How often does the shared database get reset, and who loses work when it does?
- Could a new developer, on day one, get a public URL running their branch without asking anyone for anything? Whatever they’d have to ask for is the thing to automate first.
- Does a fresh environment start from realistic data? If it starts empty, the first bug it won’t catch is the migration that’s fine on an empty table and takes forty minutes on the real one.
- Is there anything in your non-prod hosting that would still exist a month after everyone forgot about it? If so, write the sweeper before anything else. The leak starts on day one.
The Slack question goes away not because people stop asking it but because the answer is always “you are, on yours.”
