Graduating to Your Own Infrastructure: An Anonymized Case Study
A customer of ours — a casting/talent platform, details anonymized — recently moved 11 backend blocks and 4 consumer surfaces off our shared platform and onto their own cloud account. Zero code rewrite. One reversible maintenance window. This is the technical writeup, because the lessons are more useful shared than kept as internal tribal knowledge.
Full reference: 23blocks in a Box — the engineering guide.
The setup
The customer was running 11 blocks (auth, files, forms, CRM, products, search, and others) on our shared, multi-tenant platform, consumed by 4 separate surfaces — a public web app, an admin console, a mobile client, and an internal ops tool. They wanted dedicated infrastructure in their own AWS account: their own compliance boundary, their own scaling, their own on-call, no shared-tenant blast radius.
The property that made this tractable at all: every block is fully configured by environment variables. No hardcoded hostnames, no baked-in secrets, the same container image runs on shared or dedicated infrastructure unchanged. Graduating a block isn't a code change — it's a question of where the container runs and what URLs the consuming apps point at.
Six phases, four of them invisible to users
1. Fork. Each block forked into the customer's own org. No CI secrets or shared-platform config travel with it — just the code, which they now own outright and can git pull upstream on their own schedule.
2. Build. The fork's own pipeline builds images, pushes to the customer's registry. Their build, their artifacts.
3. Provision. Dedicated single-tenant footprint stood up in their AWS account — same architecture as shared 23blocks, sized for one tenant: load balancer, container services, a Postgres instance with one database per block, Redis, S3-equivalent storage, TLS.
4. Migrate data. Per-block tenant schemas copied whole; control rows filtered to the tenant; sequences advanced to MAX(id); row parity verified table by table.
Phases 1–4 happened over roughly two weeks with zero user-facing impact. Phases 5 and 6 — cutting over consumers and verifying — happened inside a single maintenance window.
The cutover window
The flip itself is designed to be boring, because boring is reversible and exciting is not.
- Freeze. All 4 surfaces stop writing — SPAs show a maintenance page, backends go process-down. One very-low-write internal tool took an accepted no-freeze.
- Final delta re-sync. Bring the dedicated database to an exact, current copy of the shared tenant. Reset sequences again. Verify parity again.
- URL-only flip. Every consumer's only change is a base URL:
*.api.us.23blocks.com→*.api.us.<customer-domain>. Same API key, valid on both sides. No redeploy of application logic, no new secrets to distribute. - New major version per app. Each of the 4 surfaces cut a new major version whose only diff is the URLs. New major = own infrastructure; previous major = shared. The version number alone now tells you where an app points, and rollback is "redeploy the previous major" — no rebuild required.
- Verify behind the maintenance page. Auth sign-in and token validation, the full read path, one real write — checked before anyone is let back in.
- Confirm zero shared traffic. Post-flip, the shared load balancer showed zero requests carrying that tenant's API key, across all 11 blocks. That's the actual definition of "done" — not "we flipped the switch," but "we can see nobody's using the old path anymore."
Total user-facing disruption: one maintenance window. Rollback stayed armed the entire time because the shared-cloud data was never mutated during the process — flipping URLs back is always clean.
Five lessons that are now baked into the tooling
These weren't known in advance. They were found the hard way, on real graduations, and are now checks the migration tooling runs automatically.
1. A data re-sync silently reverts infra-config
The tenant's control row carries infrastructure settings alongside business data — the auth JWT issuer URL, the API base URL, webhook URLs — and on shared, all of them point at shared-cloud hosts. A naive re-sync overwrites the customer's own values with the shared ones.
This is exactly what happened: after a delta re-sync, the issuer URL silently reverted, the customer's auth service started minting tokens with the shared issuer, and every login started failing validation on the new infrastructure. Nothing was down; auth just rejected its own freshly-issued tokens.
Fix: the migration now captures the target's infra-config columns before every re-sync and restores them immediately after. Data moves; infra config doesn't get overwritten by it.
2. Env-var-driven frontends don't flip when you deploy code
One of the four surfaces was a Next.js site with NEXT_PUBLIC_* API URLs baked in at build time from the hosting console's environment variables — not read from the repo at runtime. Shipping new application code did nothing to the URLs; the console env vars had to be updated and the site rebuilt before it would talk to the new infrastructure.
The team's other surfaces, Angular apps carrying config in environment.prod.ts, didn't have this trap — their config is compiled into the code artifact itself, so a new build picks it up automatically. The lesson isn't "one pattern is better," it's: know which config model each of your surfaces uses before the cutover window, because the remediation step is different for each, and you don't want to discover that live.
3. Postgres sequences don't travel with pg_dump
pg_dump / COPY moves rows. It does not move sequence state. The first insert against a freshly copied table collides on a primary key that already "exists" as far as the sequence's internal counter is concerned, because that counter reset to whatever it was in the dump, not to MAX(id).
This is easy to miss in a dry run with low write volume and then bite hard in production the moment real traffic resumes writes. Every serial sequence gets explicitly advanced to MAX(id) after every copy — dry run or real cutover, no exceptions.
4. JWT issuer identity must match the validating host, exactly
If a token's issuer claim doesn't match the host validating it, the login bounces — not because credentials are wrong, but because the token was minted for a different identity than the one checking it. This surfaced in the same incident as lesson #1, but it's worth stating as its own rule because it applies any time issuer and validator can get out of lockstep — for instance, if backend services can't all flip in the same instant.
Mitigation for staged rollouts: a dual-trust issuer window — accepting both the old and new issuer for a bounded period — smooths a cutover where not every service flips atomically.
5. Config drift across blocks is real and it will bite you at the worst time
Values that are supposed to be identical across every block's database — the tenant's API key, shared webhook URLs — tend to drift over the life of a tenant, usually from manual hotfixes applied to one block and never backported to the others. Verify these are consistent across every block before you plan a cutover, not during it. Discovering a mismatched API key mid-maintenance-window turns a 30-minute flip into a 3-hour one.
Why we're writing this down publicly
None of this is proprietary insight worth hoarding — it's the kind of thing every team doing a data-plane migration eventually rediscovers, usually at 2am. We'd rather you read it here first. It's also, not coincidentally, the same reason we're open-sourcing the blocks themselves: the code and the operational knowledge around it are both more valuable shared than protected, and we'd rather compete on doing this well than on being the only ones who know how.
If you're evaluating a similar move, the full technical reference — the complete environment-variable contract, health-check endpoints, and the six-phase process in more detail — is at 23blocks in a Box.
