Skip to content

Move terraform state to sops-git; add public assets bucket - #6

Open
MasonEgger wants to merge 1 commit into
mainfrom
tfstate-sops
Open

Move terraform state to sops-git; add public assets bucket#6
MasonEgger wants to merge 1 commit into
mainfrom
tfstate-sops

Conversation

@MasonEgger

Copy link
Copy Markdown
Member

What this does

Retires the self-referential DO Spaces state bucket in favor of sops-encrypted state committed to the repo, and adds a public DO Spaces web-assets bucket in its place.

Why

DO Spaces bills a flat $5/mo per account (not per bucket). A whole bucket plus the bootstrap dance (backend.tf.disabled, init -migrate-state, a second credential type, prevent_destroy + a destroy-ordering caveat) was a lot of machinery for one small state file. Once we wanted a public assets bucket anyway, keeping tfstate in Spaces saved nothing. So: state moves to git-sops (kills the whole bootstrap dance), and the $5 now buys asset hosting.

State: sops-encrypted in git

  • terraform/state.sops.json, whole-file age-encrypted.
  • A _tf wrapper in bootstrap/justfile decrypts before each run and re-encrypts only if the state changed (no git churn on reads, no new nonce per plan) and even on non-zero terraform exit (partial-apply state survives). All terraform recipes route through it.
  • No remote backend, no locking (fine for a solo/coordinated operator).
  • .sops.yaml rule + gitleaks allowlist added; gitignore keeps the plaintext working file out, the encrypted file in.
  • Commit terraform/state.sops.json after any apply that changes state (the wrapper prints a reminder).

Public assets bucket

  • digitalocean_spaces_bucket.assets (pytexas-assets): public read via a bucket policy (anonymous s3:GetObject, no listing), writes gated by the Spaces key, CORS for cross-site embedding.
  • Verified: key-gated PUT + anonymous GET both work. Public URL base: https://pytexas-assets.sfo3.digitaloceanspaces.com/<key>.

Also in here

  • backups: added a backup_policy block; DO no longer honors the bare backups = true toggle (was a perpetual plan diff). Confirmed backups now enabled.
  • ansible day-2 fix: _ansible auto-detects the SSH user (pytexas on a hardened droplet, root on a fresh one), since the bootstrap role disables root login after the first apply; inventory default flipped to pytexas.
  • secrets: rotated the DO API token, dropped the backend-only AWS_* mirror creds (kept TF_VAR_do_token + SPACES_*).
  • docs: rewrote CLAUDE.md, README, terraform/README, secrets/README to match.

Validation

Full destroy + fresh redeploy (nothing in prod): terraform stands up droplet, firewall, DNS, project, and the assets bucket with no bootstrap dance. just plan is clean.

Not done here

The droplet's ansible provisioning wasn't run from the work environment (a local ansible-core / community.general callback mismatch there, not a repo defect). The droplet is up and reachable, so just apply from a working controller finishes provisioning.

Reviewer notes

  • New fresh-clone flow: terraform init once, then just apply. No backend setup.
  • The DO default project can't be deleted (documented); a full teardown needs state rm + import around it.

Retire the self-referential DO Spaces state bucket in favor of
sops-encrypted state committed to the repo, and repurpose the $5/mo
Spaces spend toward a public web-assets bucket.

Why: a whole Spaces bucket plus the chicken-and-egg bootstrap dance
(backend.tf.disabled, init -migrate-state, a second credential type,
prevent_destroy + a destroy-ordering caveat) was a lot of machinery to
hold one small state file. DO Spaces bills a flat $5/mo per account, so
once we wanted a public assets bucket anyway, keeping tfstate in Spaces
saved nothing. Moving state to git-sops removes the bootstrap dance
entirely, and the $5 now buys asset hosting instead of one file.

State:
- terraform/state.sops.json holds the state, whole-file age-encrypted.
- The `_tf` wrapper in bootstrap/justfile decrypts before each terraform
  run and re-encrypts in place only when the plaintext changed, so reads
  don't churn git; it also re-encrypts on a non-zero exit so a partial
  apply's state is never lost. All terraform recipes route through it.
- No remote backend, no locking (fine for a solo/coordinated operator).
- .sops.yaml rule + gitleaks allowlist added; gitignore keeps the
  plaintext working file out and the encrypted file in.

Assets bucket:
- digitalocean_spaces_bucket.assets (pytexas-assets), public read via a
  bucket policy granting anonymous s3:GetObject (no bucket listing),
  writes gated by the Spaces key, plus a CORS config for cross-site
  embedding. Verified: key-gated PUT, anonymous GET.

Also:
- backups: DO no longer honors the bare `backups = true` toggle, so add
  a backup_policy block (weekly) to stop a perpetual plan diff.
- ansible day-2: `_ansible` auto-detects the SSH user (pytexas on a
  hardened droplet, root on a fresh one), since the bootstrap role
  disables root login after the first apply; inventory defaults to
  pytexas.
- secrets: rotate the DO API token, drop the backend-only AWS_* mirror
  creds (keep TF_VAR_do_token + SPACES_*).
- docs: rewrite CLAUDE.md, README, terraform/README, secrets/README to
  describe the sops-state workflow and assets bucket instead of the
  self-referential bucket and bootstrap dance.

Validated end to end with a full destroy + fresh redeploy (nothing in
prod): terraform stands up droplet, firewall, DNS, project, and assets
bucket with no bootstrap dance. The droplet's ansible provisioning was
not run from here (a local ansible-core/community.general mismatch in
this environment, not a repo defect); the droplet is up and reachable,
so `just apply` from a working controller finishes it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant