Decision 0004 — OpenTofu state: HTTP backend implemented in SQLite
OpenTofu HTTP state backend implemented in SQLite, sidestepping the S3 conditional-write gap.
Status
Accepted · 2026-08-21. Stale-lock recovery landed 2026-08-22: exocomp workspace unlock plus a boot-sweep warning (below).
Context
The Terraform Cloud half of the brief needs remote state, locking, version history, and
plan-gated applies. The obvious answer — an S3 bucket with use_lockfile — quietly assumes
a paid cloud provider:
- MinIO's conditional-write implementation rejects the
*wildcard etag thatuse_lockfilerequires (minio#20346). - Garage has no conditional PUT at all.
- RustFS has ETag-quoting bugs.
Those are precisely the S3-compatible stores a homelab runs. Meanwhile no packaged TACoS platform fits the scale (Terrakube is a Java multi-service stack, Burrito and tofu-controller need Kubernetes, OTF is a pre-1.0 solo project, Atlantis is PR-driven CI glue), and Semaphore UI paywalls Terraform state behind its Pro tier.
Decision
exocompd implements the OpenTofu HTTP state backend protocol itself, against its own
SQLite database. Roughly 400 LOC; the hard part (encryption) is done by tofu.
| Method | Path | Behavior |
|---|---|---|
GET |
/api/v1/tf/state/{workspace} |
latest tfstate.ciphertext, or 404 → tofu treats as empty |
POST |
/api/v1/tf/state/{workspace}?ID={lock} |
insert a new row at serial+1, verifying the lock holder |
LOCK |
/api/v1/tf/state/{workspace} |
INSERT INTO tflock; a UNIQUE primary key gives atomic locking inside SQLite's own transaction, 423 plus the lock JSON on conflict |
UNLOCK |
same | delete, checking lock_id |
Backend configuration is generated per run and points at 127.0.0.1, authenticated by a
bearer derived per workspace from the DEK: base64(Derive("tf-backend", workspace)),
recomputed by whoever needs it rather than stored anywhere. Applies are additionally
serialized by the queue's concurrency_key, so a single control plane never runs two
applies against one workspace regardless of what any storage backend can promise.
State remains encrypted by OpenTofu itself: exocompd sets TF_ENCRYPTION with the
pbkdf2 key provider and a passphrase derived per workspace from the DEK
(Decision 0003), so there is no extra secret to lose and no third
party in the DR path.
Consequences
- Locking is correct on any storage, including a NAS mount and the S3-compatible stores above, because the lock is a row rather than an object-store race.
- Every serial is retained, which yields state version history and rollback as ordinary UI objects. The pruning policy — the last 100 plus everything from the last 30 days — is designed and not yet implemented.
- Stale-lock recovery is an audited one-click
force-unlockinstead of a console trip. On boot,tflockrows whose owning job is dead are flagged for it. - Drift becomes a first-class object: a scheduled
tofu plan -detailed-exitcode, exit 2 → a plan-outcome row in the job's append-onlyjob_eventlog → notify and optionally open an approval-gated apply. The job itself succeeds, because changes being present is a fact about infrastructure rather than a failed run. - A saved plan file is a credential. It carries the backend address and the bearer it was made against, so plan artifacts live inside the 0700 data dir, and the backend address has to be the same one when a later job applies the plan. The running backend records its address in the data dir; a stale record is re-bound rather than trusted.
- The claim "state is opaque to the control plane" is false and is not used to justify
anything.
exocompdderives the passphrase. The ciphertext is opaque to backup targets. - The protocol surface is wider than four handlers:
DELETE, the customLOCK/UNLOCKverbs that some proxies drop, and the-migrate-stateinteraction. Taking over existing state is therefore a documented, reversible adoption runbook tested against fixtures —tofu init -migrate-stateonto the HTTP backend with a back-out while the old backend still exists.