Two weeks before the store launch, the entire backend moved to a brand-new AWS account. Not a disaster-recovery drill — a deliberate reorganisation: the old account had grown up as a personal sandbox, and a fresh one with proper identity management was where a product with real users belonged. The closed-test deployment kept running in the old account until cutover.
It sounds terrifying. It was mostly boring, which is the point of this post: the conventions built in the earliest parts of this series are what made it boring, and the two things that did bite had nothing to do with the data.
TL;DR — A fresh account is empty, which makes the scariest migration step (renaming every table) free: the env-scoped naming code simply deploys clean on day one, so the copy-old-to-new script written for an in-place rename went unused. What can't move: Cognito users — passwords aren't exportable, and re-signup means new ids anyway, so user-keyed data resets by design. What must change: every client build, because new pools and endpoints mean new outputs. What you forget: the old account's Hosting webhook keeps building every push, and the new Hosting app has none of your env vars. Plus the small mechanics that made multi-project accounts and warm Lambdas behave.
(Part 31 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The migration that didn't need to happen
The plan I'd been carrying was an in-place table rename: adding a project prefix to every DynamoDB table (cannycart-{Model}-{env}) so multiple Amplify apps could share one account. Renames replace resources in this stack, so in a live account that meant a copy script — walk every old table, write into the new one, cut over in a quiet hour, and pray the orphans get cleaned up. I'd written the script.
Then the decision to move accounts arrived, and the whole problem dissolved: a fresh account has no old tables. The renaming code — the same backend.ts override from the resource-naming days — just deploys clean from the first sandbox run, tables arriving with their new names natively. No copy, no cutover window, no orphans. The migration script stays in the repo, harmless and unused, in case the old account ever needs the in-place path.
The transferable lesson: sometimes the cleanest migration is a new environment. When the destination is empty, "rename" becomes "name," and a week of risk becomes a config change.
What can't move: the users
Cognito users don't migrate. Passwords aren't exportable — by design — and even a bulk re-create would issue every user a new sub, the id that keys every profile, list, item and receipt in the app. So user-keyed data resets by design with the move: closed testers re-register, and their data starts fresh.
That was acceptable because of when it happened — closed test, a dozen testers, no store customers yet. Two weeks later it wouldn't have been. If your app is already live, an account move is a different, much harder project (identity-first migration with an activation flow), and the time to move is before the first real user. The only user-independent data worth carrying was the shared Product barcode cache — global facts, no userId, optional copy.
What must change: every client build
The opposite of the rename-only scenario, where clients wouldn't have noticed: a new account means new Cognito pools and new AppSync endpoints, so every per-environment outputs file changes (Part 2's mechanism), which means new EAS builds and new store submissions once the new production backend is live. Anyone with an old build installed is pointed at a backend that's about to be turned off. The move therefore had to land before the first store release, not after — the timing wasn't a coincidence.
The mechanics that made it boring
-
The project slug in every table name —
cannycart-{Model}-{env}— is what lets multiple Amplify apps share the account: table names are account-and-region unique, and the IAM wildcards becametable/cannycart-*(deletion Lambda, replicator, post-confirmation writes). The replicator strips the prefix when deriving model names — its masking map keys on bare names — and ignores other projects' streams entirely. - SSM table-name caches got a 5-minute TTL. Lambdas read table names from Parameter Store and cache them; a warm Lambda would otherwise keep writing to an old name after a rename indefinitely. A short TTL means renames reach warm functions within minutes, no redeploy.
-
The AWS profile became overridable (
${AWS_PROFILE:-…}) instead of hardcoded across a dozen scripts — the kind of thing you fix once, during the move, so the next move is a one-line change. -
One IAM grant got tighter while we were in there: the post-confirmation trigger's write permission narrowed from
table/*to the one profile table it actually writes. Migrations are a good moment to audit the wildcards you accepted while prototyping.
The two things that bit
1. The old account kept building. The old Amplify Hosting app was still connected to the GitHub repo via its webhook — so every push to dev triggered a stale build in the old account for weeks after the move. Harmless, confusing, and quietly costing money: "the deploy failed" alerts from a backend nobody used anymore. Disconnect the old Hosting app as part of the move, not as cleanup later.
2. Env vars don't come along. The new Hosting app had none of the old one's environment variables — the AI parsing key, the email sender address. Nothing fails loudly: the backends deploy fine, and every AI-powered feature is simply dead on dev and prod until the vars are set in the console and both branches rebuild. Secrets and config live outside the repo by design, which means a new account starts with an empty set — put "re-enter every env var" in the runbook as a step, not a discovery.
And one external clock: transactional-email production access doesn't transfer either — the sending identity and its reputation belong to the old account. That restart is part of why app emails moved to a dedicated provider with a verified domain (a story the email parts tell).
What I took away
- An empty destination makes renames free. If a migration's hardest step is renaming, ask whether the answer is a new environment.
- Move before the first real user, or don't. Cognito users don't migrate; the ids that key your data change with them.
- New account, new builds. Anything that bakes endpoints into a client must ship again.
- Short-TTL your config caches. Warm Lambdas hold on to old names longer than you'd think.
- Disconnect the old webhook, re-enter every env var, and audit your wildcards — the three steps every account-move runbook should list explicitly.
Next up
Part 32: account deletion done properly — an in-app Lambda that erases server-side in the right order, a guest email round-trip that can't become an email oracle, and admin-initiated deletion with anonymous rollups.
Have you ever moved a backend between accounts? What was the thing that kept running in the old one after you thought you were done?
Top comments (0)