Skip to content

feat(infra): opt-in Cloudflare R2 remote-state backend - #167

Merged
agjs merged 3 commits into
mainfrom
infra/remote-state-r2
Jun 11, 2026
Merged

feat(infra): opt-in Cloudflare R2 remote-state backend#167
agjs merged 3 commits into
mainfrom
infra/remote-state-r2

Conversation

@agjs

@agjs agjs commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Context

Follow-up to #166. That PR hardened the VPS against deletion (OpenTofu prevent_destroy + Hetzner delete_protection/rebuild_protection). The remaining single point of failure was state itself: infra/bootstrap/ had no backend block, so OpenTofu kept a local terraform.tfstate on the operator's machine. Lose that file and you can no longer plan, reconcile, or safely destroy the stack — made worse now that the server is API-locked and must be reconciled against state to unlock.

Approach

infra/bootstrap/ is a template others clone, so remote state is opt-in and fully pre-wired — a default clone stays on local state and works unchanged; enabling R2 is a documented ~2-minute step. Backend: Cloudflare R2 (S3-compatible, no egress fees, an account already in use), with native lockfile locking (S3 conditional writes — no DynamoDB).

Changes

  • main.tf — commented backend "s3" block pre-filled with the R2-specific flags (region = "auto", use_path_style, the skip_* preflight toggles, skip_s3_checksum) and use_lockfile = true. Deployment-specific values (bucket, key, endpoints.s3) come from backend.hcl via partial config.
  • backend.hcl.example — template for the per-deployment values. Credentials stay in AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars, never on disk.
  • .gitignore — ignore backend.hcl, keep the example.
  • README.md — "Remote state (optional)" section with the init -migrate-state steps.

Enabling (documented in README)

cp backend.hcl.example backend.hcl      # fill in bucket + account id
export AWS_ACCESS_KEY_ID=<r2 access key id>
export AWS_SECRET_ACCESS_KEY=<r2 secret access key>
# uncomment the backend "s3" block in main.tf, then:
tofu init -backend-config=backend.hcl -migrate-state

Hetzner Object Storage or AWS S3 work the same way — only endpoints.s3 (and the skip_*/use_path_style flags, for true AWS) differ.

Verification

On a freshly installed OpenTofu 1.12.0:

  • tofu fmt -check -recursive — clean (and the commented block is fmt-stable when uncommented)
  • tofu init -backend=false + tofu validate — valid
  • s3 backend schema check — a scratch tofu init with the full backend block parsed every argument (use_lockfile, endpoints.s3, skip_s3_checksum, etc.) and only failed at the network layer against a deliberately-invalid endpoint — i.e. no "Unsupported argument".

Not exercised here (needs real R2 creds + bucket): an end-to-end init -migrate-state and a live lock acquire/release. Worth a one-time manual run when you create the bucket.

🤖 Generated with Claude Code

agjs and others added 3 commits June 11, 2026 20:30
Closes the last single point of failure in the protection story: state was
local-only, so losing the operator's machine/tfstate meant losing the ability
to plan, reconcile, or safely destroy the stack — worse now that the VPS
carries delete_protection.

Adds a pre-wired, opt-in path to Cloudflare R2 (S3-compatible, no egress fees,
an account we already use). Default clone stays on local state and works
unchanged; enabling remote state is a documented ~2-minute step.

- main.tf: commented backend "s3" block pre-filled with the R2-specific flags
  (region=auto, use_path_style, skip_* preflight, skip_s3_checksum) and
  use_lockfile for native locking — no DynamoDB. Deployment-specific values
  (bucket, key, endpoints) come from backend.hcl via partial config.
- backend.hcl.example: template for the per-deployment values; credentials
  stay in AWS_* env vars, never on disk.
- .gitignore: ignore backend.hcl, keep the example.
- README: "Remote state (optional)" section with the migrate-state steps.

Verified on OpenTofu 1.12.0: fmt + validate clean; the s3 backend accepts
every argument (a scratch init parsed the full config and only failed at the
network layer against a deliberately-invalid endpoint).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The provisioning-with-tofu page lagged behind the infra changes:

- "Destroying" told users `tofu destroy` wipes the server — now WRONG, since
  prevent_destroy + Hetzner delete_protection refuse it. Rewritten to document
  the deliberate two-step gate-lowering (apply to lift locks, then destroy).
- "State management" showed a bare s3 block (bucket/key/region only). Replaced
  with the pre-wired Cloudflare R2 flow this PR adds: backend.hcl partial
  config, env-var credentials, use_lockfile, and init -migrate-state.
- Prerequisites now call out the OpenTofu 1.12+ requirement.
- terraform.tfvars shape documents prevent_server_destroy.
- Design choices: updated the state bullet and added a defense-in-depth bullet
  covering both protection layers.

Verified: astro build succeeds, check:rendered-markdown passes, and
check:fragments confirms the new #destroying / #state-management /
#design-choices anchors resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dings

The local pre-push hook runs full-history `gitleaks detect` (CI is
path-filtered, so these slipped past on unrelated pushes). Two findings live
only in old commit diffs; both current files are already clean:

- add-service-to-compose.mdx: a Meilisearch example MEILI_MASTER_KEY value
  before it became the `<set-from-your-secret-manager>` placeholder.
- Auth.session.mutations.utils.test.ts: fixture token `abcdef1234567890`,
  since changed to `test-challenge-token-stub`.

Pin both by fingerprint in .gitleaksignore — narrowly scoped to these exact
commits/lines; rules stay active everywhere else and for HEAD. Verified
`gitleaks detect` (v8.30.1, the CI-pinned version) now reports no leaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@agjs
agjs merged commit 565aca4 into main Jun 11, 2026
31 checks passed
@agjs
agjs deleted the infra/remote-state-r2 branch June 11, 2026 19:05
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