How statuslin.es ships. Same code, three environments; the only thing that differs is where the values live. Decided 2026-06-13.
The app reads process.env at runtime (via requireEnv, src/lib/env.ts). How that gets
filled differs per environment:
| Local | Staging | Production | |
|---|---|---|---|
| Runs on | your machine | Fly app statuslines-staging |
Fly app statuslines |
| Database | Neon dev branch |
Neon staging branch |
Neon production branch |
| GitHub OAuth app | your existing dev app | staging OAuth app | production OAuth app |
| Domain | localhost |
staging.statuslin.es |
statuslin.es |
BETTER_AUTH_URL |
http://localhost:3100 |
https://staging.statuslin.es |
https://statuslin.es |
| Secrets live in | .env.local (gitignored) |
Fly secret store | Fly secret store |
| E2B | shared key + template | shared key + template | shared key + template |
Staging and production run the same Docker image. You validate an image on staging, then deploy that exact image (by digest) to production — never a separate rebuild.
Each candidate image is self-contained with the current plus nineteen prior distinct
/assets/** generations. The Docker build mounts the previous production image's .output
read-only, builds the current generation, then scripts/retain-build-assets.ts copies the selected
historical files and writes .output/public/assets/.asset-generations.json. Identical generations
and identical files are stored once; malformed manifests, missing files, unsafe paths, symlinks,
and same-name files with different contents fail the build.
Nitro's generated static manifest knows only about the current build. Current assets are served by
Nitro's normal static middleware; on a miss, the explicit method-agnostic /assets/** handler runs
before the catch-all renderer and serves retained GET/HEAD files with immutable caching. Other
methods return 405. Do not add a non-fallthrough publicAssets base for this directory: it would
intercept retained paths before the fallback can inspect them safely.
PREVIOUS_IMAGE defaults to the Dockerfile's empty asset-retention-bootstrap stage so the first
build has a valid /app/.output/public/assets mount. Normal staging deploys must override it with
the exact currently running production image; production still promotes the tested staging digest
without rebuilding.
Two process groups off one image (declared in fly.toml for prod and fly.staging.toml for staging):
- web —
bun run .output/server/index.mjs— public, serves site + auth + API. - worker —
bun run scripts/worker.ts— always-on poller; renders submitted scripts via E2B. No public port.
From .env.example (the committed contract):
| var | differs per env? | notes |
|---|---|---|
DATABASE_URL |
yes | the env's Neon branch; use the pooled connection string, and add sslmode=require (or verify-ca / verify-full) so the connection is encrypted — staging and prod refuse to boot without it (src/db/assert-tls.ts) |
BETTER_AUTH_SECRET |
yes | a distinct random secret per env (openssl rand -base64 32) |
BETTER_AUTH_URL |
yes | the env's public origin; everything else (dev port, auth origins) derives from this |
GITHUB_CLIENT_ID |
yes | the env's OAuth app |
GITHUB_CLIENT_SECRET |
yes | the env's OAuth app |
E2B_API_KEY |
no | one E2B account serves all envs |
RESEND_API_KEY |
yes | server-only key used to email submission review decisions |
No R2 keys — preview data is stored in Postgres (previews table), and OG card images are rendered on
the fly by the app (src/og), not stored. R2 isn't used.
Branch the existing project: dev (your current DB), staging, production. Grab each
branch's pooled connection string for that env's DATABASE_URL.
A GitHub OAuth app holds exactly one callback URL, so each host needs its own app: the dev
app already exists (callback http://localhost:3100/...), and you create staging + prod.
For each new one:
- Homepage: the env's origin.
- Authorization callback URL:
<origin>/api/auth/callback/github(e.g.https://staging.statuslin.es/api/auth/callback/github). - Copy its client ID + secret into that env's secrets.
- Sign up at e2b.dev, put the API key in your local
.env.local. - Build the custom sandbox template:
bun run build:e2b-template(bakes in jq/bc/gawk/column/strace). Serves every environment since they share the E2B account.
- Add and verify the
statuslin.essending domain in Resend, including its DNS records. - Create separate API keys for staging and production, then set each environment's
RESEND_API_KEY. - Approval and rejection emails are sent from
statuslin.es reviews <reviews@statuslin.es>with replies going tohello@statuslin.es.
fly apps create statuslines-staging
fly apps create statuslines # productionSet each app's secrets (per-env values from above). Bulk-import from a gitignored local file:
fly secrets import --app statuslines-staging < .env.staging
fly secrets import --app statuslines < .env.production.env.staging / .env.production are gitignored (the .env.* rule); they're the source you
push from, never read by the servers.
Staging and prod use separate Fly config files so each sizes its machines independently:
fly.toml— production (app = "statuslines"): webshared-cpu-2x/ 2GB, workershared-cpu-1x/ 512MB (separate[[vm]]blocks — only web needs to absorb a launch spike),min_machines_running = 1. Add web headroom withfly scale count web=N.fly.staging.toml— staging (app = "statuslines-staging"):shared-cpu-1x/ 512MB,min_machines_running = 0— the web process scales to zero when idle. (The worker is a separate background machine, not behind the proxy, so it keeps running;fly scale count worker=0stops it.)
Fly can't include one file from another, so [build], [processes], [http_service] port, and
[deploy] are duplicated in both — a header comment in each lists what must stay in sync. Only the
[[vm]] size/memory and min_machines_running differ on purpose.
Why two files instead of fly scale vm: a fly scale vm / fly scale memory change is reset
on the next deploy whenever a [[vm]] block is in the config. Prod ships by promoting the staging
image, so that promote would shrink prod back every time — the size has to live in the config file.
The sizing rationale: local repeated-wave profiling reproduced large transient SSR peaks before
delayed collection. The 2GB allocation provides operational headroom; the lower soft_limit makes
Fly add a machine before the box is in trouble.
Staging:
bun run deploy:stagingThis command reads every production machine's full image reference and requires unanimous agreement
on the registry, repository, and digest. It then passes that exact reference to the staging build as
PREVIOUS_IMAGE. Missing, malformed, or mixed production images abort before fly deploy; do not
replace this command with a direct staging deploy, because doing so drops the previous generation
that the Docker build uses to retain hashed assets.
Before any of that, it runs scripts/check-docker-context.ts, which needs a running Docker daemon.
The Dockerfile copies the whole build context into the image, so the check builds the context
locally and aborts if any file in it is git-ignored (a nested worktree's .env.local, private
notes). Fix a failure by adding the listed paths to .dockerignore, using **/ for anything nested.
The release_command in the Fly config runs bun run src/db/migrate.ts against that env's DB before
the new version goes live. That script applies migrations via drizzle-orm's runtime migrator
(drizzle-orm/postgres-js/migrator) reading the committed drizzle/ SQL folder — it does not
use the drizzle-kit CLI. So the only things the image must keep are drizzle-orm (a runtime
dependency, never pruned) and the drizzle/ folder (copied in) — verify the drizzle/ copy
survives when editing the generated Dockerfile.
One-time backfill for the configs.all_tags release (migration 0018): the migration adds the
column with a '[]' default, so existing published configs start with an empty all_tags until
it's populated. Every read path (facet pages, the home tag filter, the sitemap/llms facet list) now
derives from all_tags, so until the backfill runs the live facet pages 404 and the tag filter
matches nothing. Immediately after the migrate (before considering the env live), run the pure
re-derivation pass (no model call) against that env's DB:
DATABASE_URL='<env pooled url>' bun run scripts/backfill-tags.ts --all-tagsRun it on staging first, then production, same as the other backfill scripts. New submissions get
all_tags automatically on publish — this pass is only for rows that existed before the migration.
One-time backfill for the burn-rate tag: the burn-rate facet was added after its configs were
already classified, and the model classifier only (re)tags configs whose tags are still empty — so the
five existing burn-rate configs need a targeted pass. After deploying, run (dry run first, then --write):
DATABASE_URL='<env pooled url>' bun run scripts/tag-burn-rate.ts # preview
DATABASE_URL='<env pooled url>' bun run scripts/tag-burn-rate.ts --write # persist + recompute all_tagsIdempotent and matches by title, so it's safe to re-run. Staging first, then production. New
submissions get burn-rate from the classifier, which now knows the criterion.
One-time backfill for the weather + markets tags: same story — these feature facets were added
after their configs were classified, so the existing ones need a targeted pass. After deploying, run
(dry run first, then --write):
DATABASE_URL='<env pooled url>' bun run scripts/tag-weather-markets.ts # preview
DATABASE_URL='<env pooled url>' bun run scripts/tag-weather-markets.ts --write # persist + recompute all_tagsIdempotent, matches by title. Staging first, then production. New submissions get the tags from the classifier, which now knows both criteria.
Promote to production with the gated command — never promote by hand:
bun run deploy:prodscripts/deploy-prod.ts gates the promotion as one operation:
- It saves the current production asset URLs referenced by
/,/resources,/terms, and one linked/c/<slug>detail page. - It runs a real-browser smoke against staging. The browser requires the expected homepage title, exact root canonical, H1, and non-error body; it also fetches every same-origin initial script, stylesheet, and modulepreload and requires at least one and success for all.
- It requests every saved production asset from staging and requires status 200, a file-extension
appropriate non-HTML content type/body, and one-year immutable caching without contradictory
no-cacheorno-storedirectives. Every required production page must reference at least one qualifying same-origin asset. - Immediately before promotion it re-reads both production and staging image references. Either changing during validation aborts. The exact staging image is promoted by digest, without a rebuild.
- After promotion, every saved asset must pass the same status, type/body, and caching checks on production.
Why this is mandatory, not optional: every source gate (tsc/lint/vitest) passes while the client
bundle is dead — a server-only import leaking into the browser throws Buffer is not defined,
hydration fails, every button goes dead, and SSR still returns 200 so nothing alerts. That shipped
to prod for ~2.5h on 2026-06-29. Staging runs the exact same image we promote, so smoking staging
smokes the real production bundle. The smoke uses the signed-out checks only (the signed-in half mints
a session against the local dev DB, which staging doesn't share). Do not run the two fly deploy
commands by hand and skip the smoke — that's the hole that let the crash ship.
For each subdomain, point Cloudflare at the Fly app. First deploy DNS-only (gray cloud) so Fly can issue its TLS cert; once green, switch to proxied (orange) for CDN + WAF.
staging→statuslines-staging@(apex) +www→statuslines
Bun auto-loads .env.local for local dev (and it overrides a bare .env). Point DATABASE_URL at a
local Postgres — there's no hosted dev DB — e.g. a Docker postgres container; then
bun run db:migrate to create the schema and bun run seed:gallery for sample data. Auth runs at
BETTER_AUTH_URL=http://localhost:3100. .env.staging / .env.production are not auto-loaded —
they're only the source you push to Fly secrets. Unit/integration tests don't need any of this — they
run on PGlite against the committed migrations.
- Sign up: Fly, E2B. (Neon already exists.)
- Neon
stagingbranch; build the E2B template; create the staging GitHub OAuth app. fly launch --no-deploy— generates theDockerfile+fly.tomland creates the staging app. Then editfly.tomlfor the two process groups +release_command, and theDockerfilefor the migration caveat above. Add.dockerignore.- Set staging secrets (
fly secrets import --app statuslines-staging < .env.staging). fly deploy --config fly.staging.toml; verify on the*.fly.devURL, then pointstagingDNS in Cloudflare and verify onstaging.statuslin.es.- Shake out kinks on staging.
- Create the prod app + OAuth app; set production secrets; promote the validated image; point
apex DNS; verify the full signed-in round trip on
statuslin.es.
The render worker emits two PostHog events the maintainer can alert on. Both are fail-soft and
prod-only: captureServerEvent is a no-op when the worker has no POSTHOG_PROJECT_TOKEN, so local
and staging never emit them — the alerts only have data to act on once the worker runs in production.
render_worker_heartbeat— emitted every 5 minutes byscripts/worker.ts, DB-free (a liveness ping; no DB poll, so it doesn't keep Neon's compute warm).distinctId: 'render-worker'.render_queue_drained— emitted at the end of every drain by the worker, with{ processed, queuedRemaining, oldestQueuedAgeSec }.statusline_render_completed— emitted once per render job by the worker, with{ outcome, errorClass?, durationMs, configId, versionId, slug }.outcomeis'failure'when the job threw (errorClass: 'job_failed') or any scenario hit a sandbox error, timed out, or exited non-zero ('sandbox_error'/'timeout'/'nonzero_exit'). Never carries script output.sandbox_errorkeys off the runner's infra exit code (125), which a script can also return itself, so don't alert on it as a pure infra signal.
- Alerts attach only to trends insights (not funnels/retention).
- Check interval options: every 15 min (requires a paid Boost/Scale/Enterprise plan), hourly, daily, weekly, monthly. On the free plan the floor is hourly.
- Insight-based alerts are delayed up to ~1 hour regardless of the check interval (PostHog's own caveat) — so treat detection latency as roughly an hour, not the window size. For a faster signal you'd need a different mechanism (e.g. an external uptime check), out of scope here.
- Notify via email, Slack, Discord, or webhook. These email the maintainer.
Do NOT create them before the worker is live and emitting. The "worker down" alert fires when the
heartbeat count is below 1; created against a project with no render_worker_heartbeat events yet, it
would fire immediately and email you on a false alarm. Create both once the events appear in PostHog.
- Worker down / crashed — trends insight counting
render_worker_heartbeatover the last 15 minutes; alert when the count is < 1 → email. (At a 5-min heartbeat, two pings must be missed before it fires.) - Queue stuck — trends insight on
render_queue_drained, value = max(oldestQueuedAgeSec) over the last 30 minutes; alert when > 900 (15 min of the oldest queued job sitting unrendered) → email.
Both can be created in the PostHog UI (insight → Alerts → New alert) or via the PostHog MCP
(render_worker_heartbeat / render_queue_drained are the event names). Re-create them from this
section if the project is ever rebuilt.
- GitHub Action: auto-deploy staging on merge to main, manual approval to promote to prod.