TL;DR: Every OpenTofu stack stores its state in a backend. The bootstrap stack’s job includes creating that backend, the S3 bucket and KMS key remote state lives in. That is a chicken-and-egg problem: a stack cannot store its state in a bucket that does not exist yet. The way through is to run the bootstrap with a local backend, apply it, then rewrite the backend configuration and let tofu init -migrate-state move the state into the bucket it just created.
Where does the state of the thing that makes the state store live?
Here is a puzzle that every infrastructure-as-code setup hits exactly once, at the very beginning.
An OpenTofu stack keeps a state file, and for anything shared that state file lives in a remote backend: on AWS, an S3 bucket. Fine. But the bootstrap stack has a particular job, and part of that job is to create the S3 bucket that remote state lives in.
So walk through the first run. Bootstrap has never been applied. The state bucket does not exist, because creating it is what bootstrap is for. Bootstrap needs somewhere to store its own state. The only place that would make sense is the bucket it is about to create, which is not there yet. The thing that builds the state store cannot store its state in the state store.
Run local, then migrate
The way out is a two-step that OpenTofu supports directly.
Bootstrap starts configured with a local backend: backend "local" {}. State is just a file on the operator’s machine. With that in place, the first tofu apply runs. It creates the S3 bucket and the KMS key, and records all of it in the local state file.
Now the bucket exists. So the backend configuration is rewritten to point at it: an s3 backend block naming the new bucket. Then tofu init -migrate-state. OpenTofu sees the backend has changed, picks up the local state file, and copies it into the S3 bucket. From that point on, bootstrap’s own state lives in the bucket that bootstrap created. The egg has laid the chicken.
The local backend was a scaffold. It existed for exactly one apply, to break the ordering deadlock, and then the state moved off it and it was never used again.
It happened twice
The infra repo actually did this migration twice, and the second time is the proof that the pattern is general rather than a one-off trick.
The first migration was the one above: local to S3, at the very start. The second came later, during the move from GitHub to GitLab. GitLab offers a managed HTTP state backend, and infra chose to use it. So the backend block was rewritten again, this time from s3 to http, and tofu init -migrate-state ran again, copying the state from the S3 bucket to GitLab’s backend.
The same move, twice, against three different backends. That is the useful lesson hiding in the chicken-and-egg story. State is portable. The backend is just where you currently keep it, not a property of the stack itself, and moving it is a routine, supported operation rather than surgery.
Why this is the honest answer, not a hack
It is easy to look at “apply once with a local backend, then migrate” and feel it is a smell, a workaround for something that should have been cleaner.
It is not. It is the honest answer to a real ordering problem, and the alternatives are worse.
The obvious alternative is to create the state bucket by hand, in the console, before running bootstrap at all. But then the most important bucket in the account is unmanaged. It exists outside every OpenTofu graph, nobody’s code describes it, its encryption and policy and prevent_destroy are whatever someone clicked that day, and it drifts. The local-then-migrate dance avoids exactly that. The bucket is created by bootstrap, described in code, and tracked in bootstrap’s own state from its very first apply. It is managed from birth.
The chicken-and-egg is not a flaw to be embarrassed about. It is just the shape of the problem when a stack has to build its own foundations, and OpenTofu’s -migrate-state is the supported tool for exactly that shape.
Pulling it together
Every OpenTofu stack needs a backend to store state, and the bootstrap stack’s job is to create the backend, so on its first run the bucket it needs does not yet exist.
The resolution is to run bootstrap once with a local backend, let that apply create the bucket and key, then rewrite the backend configuration and tofu init -migrate-state the state into the bucket bootstrap just made. The infra repo did it twice, local to S3 and later S3 to GitLab, which shows the real point: state is portable, and the backend is just where you keep it. Doing it this way, rather than hand-creating the bucket, is what keeps that critical bucket managed in code from its very first day.