Secure PHI GitOps
Secure PHI GitOps — PHI / non-PHI Separation via Neon Branching
This is the branching + environment architecture that lets us keep developer velocity with database branching while enforcing strict HIPAA containment. It rests on three pillars: two isolated Neon projects (non-PHI vs PHI) with matching object-storage buckets (§6), an isolated compute boundary for the rare PHI-touching work, and automated Git ↔ Neon synchronization.
This decouples the coding-agent choice from HIPAA. Everyday development runs entirely against the non-PHI Staging project on developers' local machines, so any coding agent is fair game there — Claude Code, GitHub Copilot, Cursor, whatever — because no PHI ever reaches a laptop or a model. Only release/* / hotfix/* work touches real PHI, and it is confined to a hardened Coder workspace inside the GCP boundary, where tooling is restricted to an approved, in-boundary assistant. That confinement is what frees the everyday coding-agent choice from any compliance weight — the ~99% non-PHI case can use any assistant, while the specific in-boundary assistant for PHI work is a separate, still-open decision (WIP — the AI-Native Dev Stack effort).
1. Neon Project Configuration
Two separate Neon projects — separate projects, not two branches of one, so the PHI boundary is a hard connection-level boundary, not a policy convention.
Project A — Staging (non-PHI)
- Purpose: durable sandbox for everyday development.
- Data profile: sanitized, masked, or synthetic data only. Zero PHI.
- Access: open to all developers from standard local IDEs (localhost).
- Branching: CI creates an ephemeral Neon branch off this project's primary branch for every
feature/*PR. This is the existing PR Preview Environment mechanism, now explicitly rooted in the non-PHI project.
Project B — Production (PHI)
- Purpose: live application data and pre-release verification against real data shapes.
- Data profile: real PHI.
- Access: highly restricted. Database connections permitted only from:
- Live GCP production application instances.
- Authorized CI/CD runner identities (Workload Identity Federation; Neon IP Allow).
- Isolated Coder workspaces running inside the secure GCP subnet.
- Branching: CI creates ephemeral Neon branches off this project's primary branch only for
release/*andhotfix/*PRs.
| Staging (Project A) | Production (Project B) | |
|---|---|---|
| Data | Masked / synthetic — no PHI | Real PHI |
| Branch trigger | feature/* → dev | release/*, hotfix/* → main |
| Who connects | Any dev, localhost | Prod app · CI runners · Coder-in-subnet only |
| Ephemeral branch | pr-<n> | release-<version> |
This refines the earlier "copy-on-write from prod, then redact" model: feature work never branches from PHI at all — it branches from a separate masked project, so there is no window in which a preview branch holds real PHI. Production branches exist only for in-boundary release verification.
2. Compute & Network Boundary (GCP + Coder)
To prevent local PHI contamination during release troubleshooting:
- Deploy Coder into a dedicated, private GCP subnet, reachable only over the company tailnet (Tailscale) — the same tailnet that gates previews / staging (see Infrastructure › Tailnet Access).
- Configure VPC firewall rules so that only this subnet may reach the Neon Production project (Neon IP Allow / private networking). Nothing outside the subnet can open a connection to Project B.
- The Coder workspace image enforces an approved, in-boundary coding assistant (e.g., Gemini Code Assist; the specific coding-agent choice is still WIP, tracked under the AI-Native Dev Stack effort — not blocked on it, since any approved in-boundary tool satisfies the boundary) — arbitrary local agents are not used against PHI.
- Disable clipboard sharing and local file download/upload from the Coder workspace via policy.
- All PHI-adjacent compute stays inside the GCP BAA boundary; PHI never lands on an endpoint device.
The boundary is the point: a developer debugging a release connects to the Neon Prod branch only from inside the Coder subnet, using in-boundary tooling, with exfiltration paths (clipboard, downloads) closed. Everyday work — the vast majority — happens on the non-PHI Staging project with no such constraints.
Tailscale needs no BAA here. The Coder session is the one place PHI could traverse the tailnet; Tailscale is WireGuard, end-to-end encrypted (its relays pass ciphertext only, never plaintext), so it qualifies as a HIPAA conduit (like an ISP) — no Tailscale BAA. Keep PHI plaintext off any relay/coordination hop. See Infrastructure › Tailnet Access.
3. GitHub Branch Strategy & Protection
Two long-lived trunks — main (production) and dev (staging / integration) — plus short-lived feature/*, release/*, and hotfix/* branches. Configure protection via Settings → Branches.
main (Production)
- Require a pull request before merging: on.
- Require approvals: 1–2, and Require review from Code Owners: ON — this is the gate that routes schema / RLS changes to the CTO (see §4).
- Restrict who can push/merge: limited to the Release Managers / Lead Engineers team (this — not CODEOWNERS — is how "only release managers land
main" is enforced). - Require status checks: Integration Tests, E2E Tests, Migration Checks.
- Do not allow bypassing the above settings: on.
dev (Staging / Integration)
- Require a pull request before merging: on.
- Require approvals: 1 (peer review / quality gate). Require review from Code Owners: OFF — so schema changes flow freely into
devwithout the CTO gate. - Require status checks: Unit Tests, Staging DB Migration Checks.
release/* (Release Candidates)
- Require a pull request before merging: on.
- Require status checks: the full suite run against a Neon Production fork (real-data-shape verification).
hotfix/* (Production Hotfixes)
- Require a pull request before merging: on.
- Require status checks: the same full release suite, run against a Neon Production branch — a
hotfix/*branches off Production and targetsmain, mirroringrelease/*. It exists to patch prod urgently without waiting on the normal release train. - After merge to
main, the automated backmerge (main→dev) carries the fix downstream (Workflow C).
CODEOWNERS is path-based, not branch-based. The per-branch differences above (who approves dev vs who can land main) come from branch-protection rules + "restrict who can push," not from CODEOWNERS. CODEOWNERS decides which files require whose review on any PR — see §4.
4. CODEOWNERS Configuration
.github/CODEOWNERS enforces the quality gate by file path. Adapted to this monorepo — schema/migrations are Drizzle Kit (single migration history; the Python side reflects — no Prisma, no Supabase, no Alembic), so the paths differ from the common boilerplate:
# App code — peer + lead review
/ui/ @your-org/frontend @your-org/lead-engineers
/backend/ @your-org/backend @your-org/lead-engineers
# CI/CD + infrastructure — DevOps review
/.github/workflows/ @your-org/devops
/infra/ @your-org/devops
# Schema, migrations + RLS policies — the top-level schema/ dir (Drizzle Kit owns
# the single migration history). Owner = the CTO for now (swap for a
# @your-org/database-admins team as the org grows).
/schema/ @your-org/cto
# Analytics models (Neon → BigQuery)
/dbt/ @your-org/dataThe stock template ships /prisma/schema.prisma and /supabase/migrations/ lines — delete those. This stack has neither Prisma nor Supabase; DDL, migrations, and RLS live in Drizzle Kit under the monorepo (see Backend and Libraries).
Open schema commits on dev, CTO-gated into main. CODEOWNERS is a single repo-wide, path-based file — but "Require review from Code Owners" is a per-branch protection toggle. Turn it ON for main only and leave it OFF for dev. Then a schema / RLS change merges into dev on a normal peer approval (anyone), but reaching main — which only happens through a release/* PR — requires the CTO's Code-Owner approval. One CODEOWNERS entry, asymmetric enforcement: everyone evolves the schema upstream; only the CTO okays it for prod. (GitHub Rulesets express the same per-branch-pattern rule if you prefer them to classic branch protection.)
5. CI/CD Pipeline Workflows (GitHub Actions)
Four workflows orchestrate Neon and GitHub. GCP auth is Workload Identity Federation throughout (no long-lived keys — see PR Environments).
Workflow A — Feature Development (feature/* → dev)
- Trigger: PR opened against
dev. - Create a branch on the Neon Staging project named
pr-<number>(neondatabase/create-branch-action). - Run Drizzle Kit migrations against the Staging branch; post the
neondatabase/schema-diff-actioncomment as the review gate. - Run application tests.
- Teardown: on PR merge/close, delete the Neon Staging branch.
- Post-merge: run migrations on Staging
mainto update the baseline schema future forks branch from.
Workflow B — Release / Hotfix Verification (release/* / hotfix/* → main)
- Trigger: PR opened against
main(branch matchesrelease/*orhotfix/*). - Create a branch on the Neon Production project named
release-<version>(orhotfix-<id>). - Spin up secure CI compute (in-boundary runner) and connect to the Neon Prod branch.
- Immediately execute pending Drizzle migrations.
- Run integration / E2E tests against the migrated real-data-shape schema.
- Intervention (if needed): a developer opens a Coder workspace in the secure subnet, connects to the Neon Prod branch to debug, and commits fixes directly to the
release/*branch. - Teardown: on merge to
main, delete the Neon Prod branch.
Workflow C — Automated Backmerge (main → dev)
- Trigger: push / merge on
main. - Attempt a fast-forward or clean merge of
mainintodev. - On success: push the updated
dev. - On conflict: create
sync-main-to-dev-<timestamp>, open a PR intodev, and assign the release PR's author to resolve.
Workflow D — Staging Data Refresh (Cron)
- Trigger: weekly schedule (e.g. Sunday 02:00).
- Take a logical dump of Neon Production — inside the boundary.
- Pipe the dump through a HIPAA-capable masking / synthetic-data tool (e.g., Neosync — OSS, anonymization + synthetic; alternatives: Tonic,
postgresql-anonymizer, Snaplet seed). - Restore the masked dump into the Neon Staging project, giving developers realistic data volumes and shapes with zero PHI.
- Refresh the Staging blob base too (§6): regenerate synthetic blob fixtures into the Staging bucket for every object the masked dataset references, so masked rows resolve to masked blobs — no dangling keys, no PHI. Same in-boundary masking pass; only masked/synthetic bytes ever reach Staging.
The masking step is the compliance boundary of this workflow: it runs entirely in-boundary, and only masked output is ever written to Staging. Never restore an unmasked production dump into the Staging project, and never run the dump on a developer endpoint.
6. Object Storage (GCS) — same boundary, overlay reads
A Neon branch is only half a preview: its rows reference blobs (clinical-note attachments, uploaded documents) that must resolve to a matching, isolated store. So blobs follow the same PHI split as the database — per-class buckets plus an overlay (copy-on-write) read so a branch sees its own writes layered over a read-only base, and never mutates the base.
Buckets
| Bucket | Data | Overlay base (read-through) | Writable by | Retention |
|---|---|---|---|---|
…-uploads-prod (CMEK) | real PHI | — (authoritative) | prod app only | versioned / lifecycle |
…-uploads-phi-preview | PHI, per-branch | → …-uploads-prod (read-only) | release/* / hotfix/* CI + Coder-in-subnet | short TTL (~7d) + teardown |
…-uploads-staging | masked / synthetic | — (base) | Workflow D refresh | rolling |
…-uploads-nonphi-preview | masked / synthetic, per-branch | → …-uploads-staging (read-only) | feature/* CI + any dev | short TTL + teardown |
Each preview branch writes under its own prefix: …-preview/<branch>/<key>.
Overlay semantics (copy-on-write)
- Read
key: try the preview bucket at<branch>/<key>; on miss, read the base bucket at<key>. The base is read-only — prod/staging blobs are never touched by a preview. - Write
key: always the preview bucket at<branch>/<key>. New uploads and overwrites land in the branch's own layer. - Delete
key: removes only the preview copy. (If a preview must hide a base object, write a zero-byte tombstone under<branch>/<key>that the read path treats as "not found" — rarely needed.) - Teardown: delete the
<branch>/prefix on PR close; the short-TTL lifecycle rule is the backstop.
The PHI-preview class deliberately reads through to the prod bucket — acceptable because that class is a first-class PHI environment (release/* / hotfix/*, Coder-subnet-only). The non-PHI class reads through to the masked Staging base and holds no IAM whatsoever on the prod bucket. That asymmetry is the whole safety story: only the already-contained PHI environment can ever see real blobs.
IAM is the write-barrier
Grant each preview identity objectViewer on its base bucket (read-only) and objectAdmin on its own preview bucket only; the non-PHI preview SA gets nothing on …-uploads-prod. So "the service can't edit prod" is not a code convention — a stray write to the base is IAM-denied. (Same WIF / least-privilege model as §2 and Secret Management.)
Store logical keys, not gs:// URIs
Persist a logical object key (e.g. tenant/<id>/note/<id>) in Postgres — never an absolute gs://bucket/… URI. The storage layer composes bucket + branch prefix from environment config at call time, so the same branched DB row resolves to the prod bucket in prod, the phi-preview overlay in a release branch, and the nonphi-preview overlay in a feature branch — for free, no row rewriting.
Transparency lives in the adapter, not in GCS
GCS has no native overlay / read-through / union-bucket setting — no bucket config says "fall back to bucket A on a miss" (Anywhere Cache caches one bucket; versioning/soft-delete are within a bucket; a fronting proxy is just infra you'd write). So the overlay lives in a thin BlobStorageService every consumer goes through:
get(key) # overlay read: preview/<branch>/key → base/key
put(key, data) # always preview/<branch>/key
delete(key) # preview copy only
signed_read_url(key) # resolve which bucket holds it, then sign THAT object
signed_write_url(key) # always sign the preview object
Consumers pass logical keys only and never learn which bucket answered. The environment class (prod | phi-preview | nonphi-preview), base bucket, write bucket, and branch prefix are injected as config — the same place the Neon branch DATABASE_URL is injected. In prod, write-bucket == base-bucket and there is no overlay, so one class covers every environment. Because a signed URL points at a specific object in a specific bucket, a read URL is a server-side exists-check-then-sign — you can't hand a client a fallback URL.
How it all fits
| Concern | Mechanism |
|---|---|
| PHI never on a laptop / in a model | Local dev → Staging (non-PHI) project only |
| PHI debugging is possible but contained | Coder in a private GCP subnet (reached over the tailnet), in-boundary tooling, no clipboard/downloads |
| Feature branches can't leak PHI | Ephemeral branches come off the masked Staging project, never prod |
| Release tested against real shapes | release/* verifies on a Production Neon branch, in-boundary |
| Blobs follow the same PHI boundary | Per-class GCS buckets + overlay reads — branch layer over a read-only base; writes only to the preview bucket (§6) |
| Preview can't corrupt prod/staging blobs | IAM: read-only on the base, write only on the preview bucket; non-PHI preview has no prod-bucket access |
| Schema/RLS always reviewed | CODEOWNERS on the Drizzle path + Code-Owner review on main/dev |
| Coding-agent choice is unconstrained | Compliance weight lives only in the Coder boundary, not the everyday IDE |
See also:
- PR Preview Environments — the ephemeral per-PR branch + Cloud Run mechanics this builds on
- HIPAA Compliance — the PHI boundary + PHI-free-service rules
- CI/CD · Secret Management · Backend (Drizzle / RLS) · Developer Tooling