Neon Integration Playbook
This page is the operational playbook for Neon — the managed Postgres provider that hosts the production database. The data ownership story (which service owns which schema) lives in the Data Model spine; this page covers connection mechanics, branching, migrations, failure modes, and operational hooks that are Neon-specific.
1. Overview
Neon is a serverless Postgres with copy-on-write branching. We use it for three things:
- Production hosting of the single Postgres instance shared by
sirloin(BUN ORM, ownsusers,characters,media,billing,bi,audits,autoscaling,mcpschemas) andbrain(Prisma, ownsfennecschema). Schema ownership and the cross-service contract are documented in Data Models — Database Architecture. - Per-PR preview environments via copy-on-write branches, wired up by
.github/workflows/neon-branching.ymland pushed into Railway preview services as env vars. - Local opt-in debugging when developers need prod-like Postgres behavior
instead of the default compose
rumpdatabase.
Default dev uses local Postgres (rump:5432 in docker-compose). Local Neon
usage is opt-in by setting the same direct/pooled env vars used in preview and
production. Production Neon already lives in the US, and Railway service
manifests are pinned to us-east4-eqdc4a to keep the app runtime close to
that database path.
Railway private networking does not extend to Neon. Apps connect through Neon
direct/pooled connection strings and Neon proxy endpoints, not a
*.railway.internal hostname, so there is no Railway-only internal Neon path
that avoids Neon network accounting.
2. Architecture
flowchart LR subgraph Apps["Production services"] sirloin["sirloin (Go, BUN)"] brain["brain (NestJS, Prisma)"] end
subgraph Neon["Neon project"] pooler["Pooled endpoint<br/>(PgBouncer, transaction mode)"] direct["Direct endpoint<br/>(session mode)"] primary["Primary branch<br/>(main / production)"] pr1["preview/pr-1234-feat-x"] pr2["preview/pr-5678-fix-y"] end
sirloin -- "SIRLOIN_DATABASE_POOLED_URL" --> pooler sirloin -- "SIRLOIN_DATABASE_URL (migrations)" --> direct brain -- "DATABASE_URL (pooled)" --> pooler brain -- "DIRECT_DATABASE_URL (migrations)" --> direct pooler --> primary direct --> primary primary -. "copy-on-write per PR" .-> pr1 primary -. "copy-on-write per PR" .-> pr2Both apps point at the same Postgres branch. sirloin uses the rump
database, while brain uses the fennec database with its Prisma client
scoped to the fennec schema via ?schema=fennec. The branching workflow
rewrites every preview URL to keep this layout (see §4).
3. Per-service connections
| Service | Client | Pooled URL? | Env var(s) | Code path |
|---|---|---|---|---|
| sirloin | BUN over pgx | Yes — runtime uses pooled when set | SIRLOIN_DATABASE_URL (direct), SIRLOIN_DATABASE_POOLED_URL (pooled) | apps/sirloin/internal/pkg/env/variables.go, apps/sirloin/internal/app/config/config.go:144 (runtimeDatabaseURL), apps/sirloin/cmd/app/main.go:163 (storage.NewPostgres(cfg.DatabaseRuntimeURL, …)) |
| brain | Prisma Client (Node) | Yes — DATABASE_URL is pooled | DATABASE_URL (pooled), DIRECT_DATABASE_URL (migrations / introspection) | apps/brain/prisma/schema.prisma (url = env("DATABASE_URL"), directUrl = env("DIRECT_DATABASE_URL")) |
| brisket / strip / round / fennec / chuck / shank / flank | n/a | n/a | n/a | No direct DB access — they speak gRPC/REST to sirloin and brain. chuck (Strapi) has its own Postgres unrelated to Neon (apps/chuck/meat/.env.example). |
sirloin’s split is controlled by runtimeDatabaseURL in
apps/sirloin/internal/app/config/config.go: when SIRLOIN_DATABASE_POOLED_URL
is set, app traffic uses the pooled endpoint while migrations and tools
continue to use the unpooled SIRLOIN_DATABASE_URL. The validator enforces a
warning when the runtime URL drops sslmode below the direct URL
(apps/sirloin/internal/app/config/validator.go:177).
Confirmed: a repo-wide grep (apps/brain/src/**) finds no LISTEN,
NOTIFY, or session-level advisory-lock usage. DIRECT_DATABASE_URL is
referenced only in apps/brain/prisma/schema.prisma:15 (directUrl) and
apps/brain/create_migration.sh, which means migrations only.
Local Neon opt-in
The checked-in .env.example keeps local compose on rump. To point local
services at Neon, override only the database variables you need. Brain consumes
DATABASE_URL and DIRECT_DATABASE_URL; when the values live in the root
compose .env, use the BRAIN_* inputs that docker-compose.yml maps into
those canonical Brain variables.
The helper path is:
neonctl auth
# First run only, if your local branch does not exist yet.# Creates local/<git user.email> from staging by default:NEON_PROJECT_ID='<project-id>' NEON_CREATE_BRANCH=1 make neon-local-env
# Later runs reuse the same branch:NEON_PROJECT_ID='<project-id>' make neon-local-env
make dev-up-neon-dmake neon-local-env uses the authenticated neonctl CLI session to fetch
direct and pooled URLs for the configured project/branch, then writes an ignored
.env.neon.local file with mode 0600. If NEON_BRANCH is omitted, the
helper derives a stable branch name from git config user.email, such as
local/dev-example.com; override it with NEON_BRANCH='<branch-name-or-id>'
when needed. It rewrites the same Neon branch into Beef’s two local databases:
rump for sirloin, and fennec?schema=fennec for brain.
neonctl refuses to build a connection string when a branch holds more than one
database, and a branch forked from staging holds three (neondb, fennec,
rump). The helper therefore picks the source database itself, preferring
rump and then fennec. The pick only decides which database the fetched URL
names, since both service URLs are rewritten afterwards and the branch role owns
all three. Override it with ARGS="--database-name <db>" when needed. Roles are
not auto-detected: if a branch has more than one, pass
ARGS="--role-name <role>". New branches fork from
staging by default; pass NEON_PARENT_BRANCH or
ARGS="--parent-branch <branch>" to override. Pass NEON_BRANCH_EXPIRES_AT to
set an expiry.
The helper does not accept or forward Neon API keys; run neonctl auth locally
instead.
By default it leaves SIRLOIN_DATABASE_POOLED_URL empty because sirloin
migrations, startup session setup, and advisory-lock flows need the direct URL.
Pass ARGS=--include-sirloin-pooler only when you are explicitly testing
sirloin’s pooled runtime path.
Manual URL fallback:
NEON_DIRECT_URL='postgresql://<role>:<password>@<direct-host>/<db>?sslmode=require' \NEON_POOLED_URL='postgresql://<role>:<password>@<pooled-host>/<db>?sslmode=require' \make neon-local-env# sirloin: direct URL is required for startup migrations and advisory locks.SIRLOIN_DATABASE_URL=postgresql://<role>:<password>@<direct-host>/rump?sslmode=require# Optional. Set only when you are explicitly testing pooled runtime queries.SIRLOIN_DATABASE_POOLED_URL=postgresql://<role>:<password>@<pooled-host>/rump?sslmode=require
# brain: runtime may use the pooler, migrations must use the direct URL.# Host-run Brain / Railway / apps/brain env:DATABASE_URL=postgresql://<role>:<password>@<pooled-host>/fennec?sslmode=require&schema=fennecDIRECT_DATABASE_URL=postgresql://<role>:<password>@<direct-host>/fennec?sslmode=require&schema=fennec
# Root compose .env equivalent:BRAIN_DATABASE_URL=postgresql://<role>:<password>@<pooled-host>/fennec?sslmode=require&schema=fennecBRAIN_DIRECT_DATABASE_URL=postgresql://<role>:<password>@<direct-host>/fennec?sslmode=require&schema=fennecdocker-compose.yml passes pre-set values through to sirloin and brain.
make dev-up-neon-d starts compose without the local rump service, with
.env.neon.local layered over the normal .env file, so other local secrets
can stay where they already live. Override NEON_COMPOSE_SERVICES if you only
need a smaller subset such as brain sirloin redis round.
apps/brain/create_migration.sh rewrites only the compose host rump to
localhost:8800; Neon hosts are left untouched and local rump is not started
for that path.
4. Branching
.github/workflows/neon-branching.yml runs on pull_request
opened|reopened|synchronize|closed, for every base branch.
- Setup job sanitizes the PR branch name (slashes → dashes).
- Create job (
create_neon_branch_and_update_railway):- Validates
secrets.NEON_API_KEY,vars.NEON_PROJECT_ID,secrets.RAILWAY_API_TOKEN,vars.RAILWAY_PROJECT_ID. - Calls
neondatabase/create-branch-action@v6withbranch_name: preview/pr-${PR_NUMBER}-${SANITIZED_BRANCH}and a 14-day TTL (expires_atderived fromdate -u --date '+14 days'). - Reads
db_url(direct) anddb_url_pooledfrom the action outputs and rewrites them with Python (urllib.parse) into three flavours:/tmp/rump_db_url— direct, points at therumpdatabase./tmp/rump_db_pooled_url— pooled, points atrump./tmp/fennec_db_url— direct +?schema=fennecfor brain.
- Installs the Railway CLI, waits for the matching preview environment
beef-pr-${PR_NUMBER}to exist, then sets:beef-sirloin→SIRLOIN_DATABASE_URL,SIRLOIN_DATABASE_POOLED_URLbeef-brain→DATABASE_URL,DIRECT_DATABASE_URL
- Deletes
BRAIN_OTEL_URLandSIRLOIN_AXIOM_TOKENin the preview env so PR traffic never lands in the staging Axiom dataset. - Verifies every var was actually written and does not still point at a
*.railway.internalhost — fails the job otherwise.
- Validates
- Cleanup job (
delete_neon_branch,if: github.event.action == 'closed'): invokesneondatabase/delete-branch-action@v3to drop the branch when the PR is merged or closed. The 14-day TTL is the safety net for PRs that bypass close events.
Branch naming: preview/pr-<NUMBER>-<SANITIZED_HEAD_BRANCH> — keep this
prefix; the cleanup action and the schema-diff action both depend on it.
5. Connection pooling
Neon ships its own PgBouncer-based pooler. We use it from both services in
transaction pooling mode (the default for Neon’s -pooler host).
Quirks to remember:
- No session-scoped state —
SET,LISTEN/NOTIFY, prepared statements outside the transaction, and session-level advisory locks (pg_advisory_lock) do not survive across queries. If you need any of these, point the call at the direct URL (SIRLOIN_DATABASE_URL/DIRECT_DATABASE_URL). - Prisma:
pgbouncer=trueis implied by Neon’s pooler hostname. Migrations andprisma db pullmust usedirectUrl, which is exactly howapps/brain/prisma/schema.prismais wired. - BUN / pgx: server-side prepared statements are off by default with the
Neon pooler.
apps/sirloin/internal/pkg/storage/postgres_config.goconfigures the pgx pool; review it before turning on any prepared-statement caching. - Migration runner:
sirloin’s Go runner takes its own advisory lock (postgresLockPoolConfiginapps/sirloin/internal/pkg/storage/) — point it at the direct URL, never the pooled URL.
6. Migrations
| Service | Tool | Source of truth | CI invocation |
|---|---|---|---|
| brain | Prisma migrate | apps/brain/prisma/migrations/ (timestamped, e.g. 20241231153339_init_fennec) | pnpm exec prisma migrate deploy from apps/brain/startup.sh:9 (runs at container start). New migrations are authored locally via apps/brain/create_migration.sh (prisma migrate dev) against canonical DIRECT_DATABASE_URL, using root BRAIN_DIRECT_DATABASE_URL only as the compose input. |
| sirloin | Custom Go runner | apps/sirloin/internal/app/migrate/migrate.go + SQL in apps/sirloin/internal/app/migrate/schema/*.sql | make verify-migrations (numbering check) gates CI; runner executes against SIRLOIN_DATABASE_URL (direct). |
For preview branches, the workflow does not run migrations itself — the
deployed Railway service runs them on container start (startup.sh for
brain). Because the branch is a copy-on-write of the production primary, all
existing migrations are already applied; only the new ones in the PR run.
Confirmed: sirloin runs migrate.Migrate(cfg.DatabaseURL) at process boot
in apps/sirloin/cmd/app/main.go:154. The Go binary executes migrations
against the direct URL (SIRLOIN_DATABASE_URL) on every container start,
identical to brain’s prisma migrate deploy flow — no manual deploy step.
7. Failure modes
| Mode | Detection | Mitigation |
|---|---|---|
| Cold-start latency on suspended branches — Neon suspends idle compute. First query after suspension can take 0.5–3s. | First-request P99 spike on a freshly-deployed preview; connection refused/timeouts immediately after deploy. | Send a warmup query in app boot. For previews, accept the cost — these are short-lived. |
| Connection-pool exhaustion at the pooler | FATAL: sorry, too many clients already or pgx acquire timeouts | Reduce per-pod pool size (we share one pooler across services); shed long-running transactions; check for leaked txns in BUN/Prisma. |
Migration deadlock during prisma migrate deploy against pooled URL | Migration hangs; advisory-lock timeout | Always run migrations against DIRECT_DATABASE_URL / SIRLOIN_DATABASE_URL. Already enforced in code paths above — regressions usually mean someone wired the pooled URL into directUrl. |
| Branch quota hit — Neon project hits max-branch limit | create-branch-action fails the workflow | The 14-day TTL plus the on-close delete keep us under quota. Manually purge stale preview/pr-* branches via neon branches list if hit. TODO(@law): exact branch quota on the current plan. |
| Pooled URL leaks into migrations env | prepared statement "sN" already exists errors during prisma migrate deploy | Keep canonical DIRECT_DATABASE_URL on the direct Neon endpoint. In root compose .env, set BRAIN_DIRECT_DATABASE_URL so compose passes that direct URL through to Brain. |
sslmode=disable in production URL | sirloin validator emits warning at boot (validator.go:191) | Production Neon URLs must include sslmode=require. The dev .env uses sslmode=disable against the local rump container — that is expected and limited to dev. |
8. Secrets
Per-environment DATABASE_URL shapes:
| Env | Where stored | Format |
|---|---|---|
| dev | .env.example (root) | Defaults point at compose rump: SIRLOIN_DATABASE_URL=postgresql://sirloin:sirloin@rump:5432/rump?sslmode=disable, root BRAIN_DATABASE_URL=postgresql://sirloin:sirloin@rump:5432/fennec?sslmode=disable&schema=fennec maps to Brain’s DATABASE_URL, with optional direct/pooled overrides left empty. |
| preview | Railway env vars on beef-pr-<N> services | Written by neon-branching.yml from the create-branch action outputs. Never logged. |
| staging / production | Railway env vars on beef-* services in their respective environments | TODO(@law): canonical hostname pattern (e.g. ep-<id>-pooler.<region>.neon.tech) and whether we use a single Neon project across staging+prod or one per environment. |
Rotation path: rotate the Neon role password in the Neon console → update
the four Railway vars (SIRLOIN_DATABASE_URL, SIRLOIN_DATABASE_POOLED_URL,
DATABASE_URL, DIRECT_DATABASE_URL) on each environment → redeploy. The
preview workflow re-derives every URL on each PR sync, so it self-heals after
a rotation.
Never log DATABASE_URL. The branching workflow is explicit about this
(comment near db_url_with_pooler in the Neon action docs).
9. Backups & PITR
- TODO(@law): production backup schedule and RPO / RTO targets.
- TODO(@law): Neon point-in-time recovery window on the current plan (Neon offers PITR up to N days depending on tier).
- TODO(@law): retention policy for
StripAuditLog,FraudEvent,Generationrows — see Data Models.
PII surface that lives on Neon: brain.fennec.User.email/firstName/lastName,
sirloin.users.*, sirloin.subs_all.email, plus anything keyed off
Chargebee customer IDs. Treat any Neon export as a PII export.
10. Cost model
Neon bills on three axes — TODO(@law) all numbers against the current plan:
- Compute hours per branch (autosuspend keeps idle cost near zero; preview branches dominate this if PRs sit open).
- Storage for the primary (copy-on-write means branches add only their diff).
- Data transfer out of the region.
Cost levers we already pull: 14-day branch TTL, on-close cleanup, autosuspend on the production branch (Neon default).
11. Dashboards
- Neon console — TODO(@law): paste link to the Beef project dashboard.
- Branch list / quota — TODO(@law): link.
- Slow query insights — TODO(@law): whether Neon’s built-in insights are
enabled or whether we rely on Axiom traces (
SIRLOIN_AXIOM_TOKEN).
12. Runbook hooks
- Operations — Railway — covers the Railway side of the preview-environment wiring.
- TODO(@law): once the Neon-specific runbook lands under
docs/src/content/docs/operations/runbooks/, link it here (suggested:neon-branch-purge.md,neon-pool-exhaustion.md). - For schema-ownership questions, use Data Model as the spine.