Feature Specification: Component terraform-backend
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/components/hosting-provider/terraform-backend/default.nix, core/components/hosting-provider/terraform-backend/terraform-backend.nix, core/effects/tf/common/terraform-backend.nix).
User Scenarios & Testing (mandatory)
User Story 1 - Keep the OpenTofu state of the group (Priority: P1)
A hosting provider enables terraform-backend on the tf-backend node. The
node runs the nimbolus/tf-backend HTTP state backend. The OpenTofu
deployment methods store their state and take their locks there.
Why this priority: Every tf deployment program reads and writes its
state through this backend. Without it, no steady-state deploy can run. See
Stateless Deployment Programs.
Independent Test: nix-unit-setups-terraform-backend-config evaluates the node of the component alone. It checks that the nginx front sends requests to the port that the backend binds and serves only TLS, that the port is open but not published, that the kmsKey is the file of generateFiles from the on-machine provider, that the service waits for generate-vars.service and not for an OpenBao agent, and that a switch does not restart the service. deployment-tf-hosts, deployment-tf-incus-hosts, deployment-tf-incus-operator, apps-tf and apps-tf-incus deploy it, and deployment-tf-self-state-reachable reaches it.
Acceptance Scenarios:
- Given the component is enabled, When the node activates, Then
terraform-backend.servicelistens on:8080and nginx serveshttps://<domain>/as a proxy tohttp://127.0.0.1:8080. - Given the node is up, When a deployer runs
tofu initagainsthttps://<domain>/, Then the backend stores the state encrypted with the node'skmsKey.
User Story 2 - Bootstrap a fresh provider before public DNS and TLS (Priority: P2)
On a fresh provider, public DNS and a public certificate do not exist yet. A
BOOTSTRAP=1 migrate reaches the backend over the Incus bridge on port
8080.
Why this priority: The state of the graph that repairs the cluster must not depend on public DNS, a public CA, OpenBao or SPIRE.
Independent Test: deployment-tf-hosts-bootstrap and integrations-migrate-after-failed-apply.
Acceptance Scenarios:
- Given a fresh provider, When the deployer migrates with
BOOTSTRAP=1, Then it reaches the backend at<node>:8080from aninternalSourcesaddress. - Given the TLS policy of the group, When it reads
certAudience = "bootstrap", Then it does not issue the certificate of this node from a public CA. Seenix-unit-setups-tls-routing. - Given OpenBao attestation is broken, When the node starts, Then
generate-varsstill renders thekmsKeyon local disk and the backend starts.
User Story 3 - Recover a lock that a stopped client left (Priority: P3)
An operator finds a stale lock after a client stopped before its UNLOCK,
for example a tofu that got a SIGKILL during an apply. The operator runs
the force-unlock verb of the effects library or of the CD workflows.
Why this priority: Only the client that holds a lock releases it, so a lock stays until an operator releases it. A restart of the backend clears all locks, because the lock table is in memory.
Independent Test: integrations-reclaim-own-lock and integrations-steady-backend-freshness.
Acceptance Scenarios:
- Given a lock that no client holds, When the operator runs
force-unlock, Then the backend accepts it becauseFORCE_UNLOCK_ENABLEDistrue. - Given a change to the backend unit, When the node switches, Then systemd does not restart
terraform-backend.service.
Edge Cases
- A restart of the service during an apply clears the in-memory lock table. Each later state write returns
400with an empty body, and the state after the apply is lost. - A change to the unit takes effect only after a manual restart, when no apply is in flight.
- The
kmsKeyis generated once if it is absent. A new key makes the existing encrypted state unreadable. The offsite backup holds the key with the states, thus a restore brings back both. A person who can read the backup repository can thus read the states. - A state write replaces the old file in place. If a state changes during the copy of the backup, the backup copies the states again, at most 5 times, and then fails.
- The restore stops and starts
terraform-backend.service, which clears the lock table. Do a restore only when no apply runs. - The backup unit gets its S3 key from OpenBao KV. If OpenBao is broken, the backup fails, but the backend continues to serve the states.
- Port
8080is never published. The firewall resource admits it only frominternalSources.
Requirements (mandatory)
Functional Requirements
- FR-001: The component MUST declare
name = "Terraform backend",description = "OpenTofu HTTP state backend.", nopackage, andcertAudience = "bootstrap". - FR-002: The component options MUST come from
sharedOptions.nixwith a defaultsubdomainoftf-backend, read fromterraform-backend/node-name.nix. - FR-003: When enabled, the component MUST request
ssl.domains = [domain]in grouptlsandspiffe.enablein groupspiffe. Seesslandspiffe. - FR-004: When enabled, the component MUST request a
nixos-modulein groupcodethat importsterraform-backend.nixand servesdomainthrough nginx with a proxy tohttp://127.0.0.1:8080. - FR-005:
terraform-backend.nixMUST enableservices.terraform-backendwithLISTEN_ADDRdefaulting to:8080andopenFirewall = true. - FR-006:
terraform-backend.nixMUST setsystemd.services.terraform-backend.restartIfChanged = false. - FR-007:
core/effects/tf/common/terraform-backend.nixMUST import the service module from thenixpkgs-terraform-backendpin. - FR-008: It MUST mint
kmsKeythroughgenerateFilesas 32 random bytes in base64, owned byroot, and pass it askmsKeyFile. - FR-009: It MUST force
contracts.varsBackend.defaultProviderto theon-machineprovider, so that no generated file of the node depends on OpenBao. - FR-010: It MUST set
FORCE_UNLOCK_ENABLED = true. - FR-011:
terraform-backend.serviceMUST order after and wantgenerate-vars.service, and MUST NOT wait foropenbao-agent-default. - FR-012: The implementation MUST request
garagethroughmkBackupsGarageRequestwith the bucketterraform-backend-backups, andbackup.nixMUST add astreamingBackupto that bucket with the restic provider, as the userroot. The stream MUST be a tar of a copy of the states that is the same as the states after the copy, and of thekmsKey. - FR-013: The restore MUST stop
terraform-backend.service, replace the states and thekmsKey, give the states the owner of the state directory, and start the service again.
Key Entities
- State backend:
nimbolus/tf-backendasservices.terraform-backend, on:8080, fronted by nginx on:443. kmsKey: the state-at-rest key, local to the node, fromcontracts.generateFiles.results.terraform-backend.files.kmsKey.path.- Lock table: in memory, in the backend process.
- Offsite backup: the restic repository
terraform-backend_statesin theterraform-backend-backupsbucket, with the states and thekmsKey.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001:
deployment-tf-hosts,deployment-tf-incus-hosts,deployment-tf-incus-operator,apps-tfandapps-tf-incuspass with state on this backend. - SC-002:
deployment-tf-hosts-bootstrapandintegrations-migrate-after-failed-applypass over the:8080lane. - SC-003:
integrations-reclaim-own-lock,integrations-steady-backend-freshness,integrations-steady-refuses-lost-stateanddeployment-tf-self-state-reachablepass. - SC-004:
integrations-tf-backend-state-idpasses: a state written with the default credentials of the clients has the ID that the existing states on a live deployment have.
Assumptions
- The node that runs the component has the
network.nametf-backend, which the hosting-provider group reads fromterraform-backend/node-name.nix. The zone of the group publishes a served name equal to the node label with no alias. - The
firewallresource limits port8080tointernalSources. - The
nixpkgs-terraform-backendpin carries the branch of nixpkgs pull request 554986 until the module is in thenixpkgspin.
Known Deviations
- The lock table lives in memory in the backend process. This is hidden state against principle III, and a restart during an apply loses the state written after it.
restartIfChanged = falsemeans that a change to the unit needs a manual restart.terraform-backend.nixhas a TODO:nimbolus/tf-backendhas no OpenTelemetry support, so the component sends no traces or metrics.- The forced
on-machinevars provider applies to every generated file on the node, not only to thekmsKey. - The
basicauthentication of the backend accepts every request. The password only selects the ID of the state, which issha256("<password>:" + sha256("<project>-<name>")). Thus each client that can reach port8080or the nginx front can read, write and unlock each state whose key and password it knows, and all clients use the same fixed password.FORCE_UNLOCK_ENABLED = trueadds no access to this: aLOCKon a locked state answers423with the record of the holder, and anUNLOCKwith that record releases the lock. A change of the password or of the authentication method changes each ID, and the existing states then need a migration. - The backup of this component uses the fixed and public restic password
ReallySafePasswordTrustMe39, because a restore of this backup comes before the secret store can give a password (#2102). Thus the encryption of this repository gives no protection.