# Dev, staging and prod from day one: why a team of three shouldn't wait

"We'll separate environments later, when we have users." We've heard that sentence from every small team we've worked with, and we've said it ourselves. It sounds like prudence: don't build infrastructure you don't need yet. It's actually a loan, taken out at the worst possible interest rate, and this article is the repayment schedule.

We [wrote the how-to](/en/blog/aws-three-accounts-one-cdk-codebase) for running three AWS accounts from one CDK codebase. This is the why, aimed at the team that has one account with everything in it and a good reason to keep it that way for another quarter.

## What "later" costs

Separating environments on day one is a day of work. Separating them at month six is a week. Separating them at month eighteen is a month, plus a risk you can't price. The cost doesn't grow linearly; it grows with the number of things that have accumulated state and names.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Chart of the effort to separate environments against the age of the project. At day one it is about one day. At month six it is about one week, because the database, secrets and DNS have real data and real names. At month eighteen it is about a month, with a shaded risk band, because production data has to be moved out of the shared account without downtime and every integration points at the old account. A second flat line shows the running cost of having three environments from the start: about 60 dollars a month.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Effort to separate environments vs. project age</text>
<line x1="70" y1="220" x2="870" y2="220" stroke="#9aa3c7"/><line x1="70" y1="40" x2="70" y2="220" stroke="#9aa3c7"/>
<text x="70" y="240" text-anchor="middle" fill="#9aa3c7" font-size="10">day 1</text><text x="370" y="240" text-anchor="middle" fill="#9aa3c7" font-size="10">month 6</text><text x="770" y="240" text-anchor="middle" fill="#9aa3c7" font-size="10">month 18</text>
<text x="64" y="216" text-anchor="end" fill="#9aa3c7" font-size="10">1 day</text><text x="64" y="150" text-anchor="end" fill="#9aa3c7" font-size="10">1 week</text><text x="64" y="60" text-anchor="end" fill="#9aa3c7" font-size="10">1 month</text>
<path d="M70,214 C200,212 300,180 370,150 C500,110 650,80 770,56" fill="none" stroke="#ff6b8a" stroke-width="2.5"/>
<path d="M600,90 C700,70 770,56 800,50 L800,120 C740,110 650,120 600,130 Z" fill="#ff6b8a" opacity="0.12"/>
<text x="700" y="140" text-anchor="middle" fill="#ff6b8a" font-size="10">+ risk: moving prod data out of a shared account</text>
<circle cx="70" cy="214" r="5" fill="#4fffb0"/><text x="84" y="206" fill="#4fffb0" font-size="11">3 accounts, 1 config map, 1 OIDC role each</text>
<circle cx="370" cy="150" r="5" fill="#ffd166"/><text x="384" y="146" fill="#ffd166" font-size="11">database, secrets, DNS have names and data</text>
<circle cx="770" cy="56" r="5" fill="#ff6b8a"/><text x="760" y="46" text-anchor="end" fill="#ff6b8a" font-size="11">every integration points at the old account</text>
<line x1="70" y1="200" x2="870" y2="200" stroke="#4fffb0" stroke-width="1.5" stroke-dasharray="5,4"/><text x="868" y="196" text-anchor="end" fill="#4fffb0" font-size="10">running cost of 3 envs from day 1: ~$60 / month</text>
</g>
</svg>
</div>

At month six the database has real data and a real name. Secrets have been copied into three places. DNS points at things. Someone's laptop has a profile called `default` that deploys to the only account there is. Moving production out of that account means [importing stateful resources into new stacks](/en/blog/cloudformation-500-resource-limit-split-a-stack), re-pointing every webhook and integration, rotating every secret that was shared, and doing it without downtime. We've done it. It's a project with a runbook, and the runbook has a rollback section, and you'd rather not need one.

## The three things that go wrong in one account

We're not arguing from theory. Every one of these happened to a team we've worked with, and one of them happened to us.

**Test data in production, or production data in test.** With one account, "the database" is one cluster, and the separation is a schema name or a table prefix. A migration script with the wrong `search_path`, a seed command run in the wrong shell, an analytics query pointed at the wrong table: each is a one-character mistake and each one has happened. In separate accounts, the wrong-account mistake is still possible, but it requires assuming a different role, and the deploy tooling can [refuse to do that without an explicit flag](/en/blog/same-tag-deploy-and-the-deploy-script-without-ci).

**A deploy that was meant for dev.** [Our incident](/en/blog/cloudformation-deleted-our-app-runner-services): a `cdk deploy` from a laptop, with the wrong context, deleted every App Runner service in an environment. It was the dev environment, in the dev account, and the total damage was an afternoon. The same command, in a single-account setup, deletes production. The account boundary didn't prevent the mistake; it bounded it. That's the whole value.

**The "just this once" hotfix.** A single account with no staging means the only place to test a fix is production. So the fix goes to production, and it works, and the next one does too, and eventually the team's deploy process is "push to main and watch". The habit isn't stupidity. It's the rational response to not having anywhere else to look. Give people a staging account that costs $20 a month and they use it, because it's there.

## What "from day one" actually means

The objection to early separation is usually a picture of a landing zone: Control Tower, an organisation with a dozen OUs, service control policies, a shared-services account, centralised logging, a network account with Transit Gateway. That's a month of work and it's the right thing for a company with fifty engineers. It's the wrong thing for three, and it's not what we're proposing.

The minimum that captures nearly all of the value:

| Do on day one | Skip until you need it |
|---|---|
| An AWS Organization with three member accounts: dev, staging, prod | Control Tower, landing zone accelerators |
| One `config` map in the CDK codebase, keyed by environment | Organisational units beyond the default |
| One [OIDC deploy role per account](/en/blog/github-oidc-deploy-roles-per-aws-account), scoped by branch | Service control policies |
| `RemovalPolicy.RETAIN` and deletion protection in prod | Shared-services or network accounts |
| A consolidated billing view tagged by environment | Centralised CloudTrail into a security account |
| A deploy script that refuses prod without `--env prod --yes` | Cross-account VPC peering |

That's a day. The CDK diff to go from one environment to three is the config map and the `env` context value; everything else in the stack is already parameterised or should be. And the three accounts cost, at minimum size with Aurora paused and App Runner at 0.25 vCPU, [about $60 a month more](/en/blog/aws-bill-of-a-three-person-startup) than one. That's the entire premium, and it's less than one hour of the engineer who'd otherwise be doing the month-eighteen migration.

## What you get on day two

Separation isn't just a safety property. It's what makes several other good things possible, and those are the ones that pay for it:

- **[Preview environments per pull request](/en/blog/preview-environments-per-pull-request-on-aws)** need a place where anyone can deploy anything without asking. That's the dev account, with a permissive OIDC trust policy that would be unacceptable in prod.
- **A production deploy that a human approves** needs a GitHub environment with reviewers, and a role that only that environment can assume. That's the prod account's trust policy.
- **Honest cost tracking.** "How much does production cost?" is a filter on account ID, not an archaeological project through tags.
- **Blast radius for credentials.** A leaked dev key can't touch prod data, because it's not in that account. We [deleted our long-lived keys](/en/blog/github-oidc-deploy-roles-per-aws-account) anyway, but the boundary is the layer beneath that.
- **Hardening that differs per environment without branching in code.** DESTROY in dev, RETAIN in prod, point-in-time recovery on in staging and prod, from one table in one file.

None of these are possible in a single account without building the isolation inside it by hand, with IAM policies that are harder to get right than the account boundary they're imitating.

## The one-account case that's actually fine

If you're building a prototype that will be thrown away in eight weeks, one account is fine, and separating it is waste. The test is whether there's a database with data you'd be sad to lose. The day the answer is yes is the day you're past the prototype, and it's usually earlier than the team thinks: the first time a real user signs up, the first time an invoice is generated, the first time someone says "don't run that against the live one".

Do it on that day, not on the day of the incident.

If you have one account with everything in it and want the day-one setup retrofitted before it becomes the month-eighteen project, [talk to us](/contact). We've done both, and the first one is much cheaper.
