infra-edge-lab · v0.1.0
0013. Encrypted state in R2 with a lock file
- Status: accepted
- Date: 2026-10-21
Context
The state of an environment holds addresses, resource IDs and secrets: the client secrets of the Access service tokens and, for a cloud server started with a tunnel token, the first-boot user data. The operator works from a workstation and CI plans from a runner, so the state must live outside both, must not be readable by whoever gets a copy of the stored object or the laptop, and must not be written by two runs at once. Cloudflare R2 is S3-compatible and has a free tier, but it keeps no object versions, and its documentation does not mention OpenTofu’s S3 lock file. No R2 bucket exists yet for this project.
Considered options
- Local state files only, copied around by hand.
- A managed backend service that stores and locks state.
- The S3 backend on R2 with OpenTofu’s lock file (
use_lockfile), and the state and plan files encrypted by OpenTofu before they leave the machine.
Decision
Chosen option: 3, because it needs no extra service or account beyond the zone’s provider, works the same on a workstation and in CI, and leaves the storage provider with ciphertext only.
lab,demoandshowcasekeep their state in the bucketluminare-tfstate, one key per environment (<env>/terraform.tfstate), withuse_lockfile = true. The account-specific endpoint and the credentials are not in the code: they come fromAWS_ENDPOINT_URL_S3,AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYin the environment’s SOPS file (ADR 0006). Without the endpoint the S3 backend would send its requests to AWS, sotask initandtask planrefuse to use the remote backend withoutAWS_ENDPOINT_URL_S3.- Every environment has the same
encryption.tofu: a PBKDF2 key fromTF_VAR_state_passphrase(at least 16 characters), AES-GCM,enforced = truefor state and plan files. It is a.tofufile, so Terraform and TFLint never read it (ADR 0005). No fallback is committed; a migration or a passphrase change adds one for one run only (runbook). - The
localenvironment keeps its state in a local file, encrypted the same way, with a random passphrase per run: its containers and its state are thrown away together. task init ENV=<env> BACKEND=localwrites a git-ignoredbackend_override.tf, so a remote environment can be planned or tried without the bucket, still encrypted.
Consequences
- Good: a copy of the state object, a plan file or a stolen workstation without the passphrase reveals nothing; no endpoint, account ID or key is in the repository.
- Bad: the passphrase is the state. Lost passphrase, lost state: it is kept in the SOPS file and offline, and recovering without it means importing every resource again.
- Bad: the state can be read with OpenTofu only, not with Terraform.
- Bad: whether R2 honours the conditional write behind
use_lockfileis not tested yet (no bucket). Until a lock test passes, the rule is one operator applying at a time, and a CI plan with a read-only token, which cannot write the lock file, runs with-lock=false. - Bad: R2 keeps no old versions of an object; a backup of the state after each apply is a separate task.
docs/adr/0013-encrypted-state-in-r2-with-a-lock-file.md on GitHub
Licensed under Apache-2.0. Source: luminare-dev/infra-edge-lab at v0.1.0.