Skip to content

State and native cloud

Immutable OpenTofu state history, locking, authenticated downloads, and migration through the native cloud contract.

Updated View as Markdown

StackShip implements OpenTofu’s native cloud/TFE-compatible state contract. Customers configure a cloud block and authenticate with tofu login; they do not configure the generic HTTP backend.

Discovery

https://stackship.run/.well-known/terraform.json advertises:

  • OAuth login through https://api.stackship.run/api/auth;
  • the compatibility API at https://api.stackship.run/api/v2/; and
  • the module registry protocol (modules.v1) at https://api.stackship.run/registry/modules/v1/.

stackship.run remains the preferred cloud hostname. For existing configurations that use api.stackship.run, the API publishes the same discovery contract and redirects OpenTofu’s API-hosted run links to the canonical dashboard route.

The OpenTofu login callback range is inclusive: StackShip registers http://localhost:10000/login through http://localhost:10010/login. Login uses authorization code plus PKCE S256; no client secret is embedded in OpenTofu.

Configure:

terraform {
  cloud {
    hostname     = "stackship.run"
    organization = "example"

    workspaces {
      name = "network-foundation"
    }
  }
}

Then authenticate:

tofu login stackship.run

Automation can use a separately issued, scoped service-account token through TF_TOKEN_stackship_run. Managed execution uses that same hostname-derived environment variable for a different credential: StackShip injects a separate opaque rat_ attempt credential derived with RUNNER_RUN_TOKEN_HMAC_KEY. The customer’s interactive or service-account credential is never passed into the sandbox.

The managed credential is accepted only for the exact organization, project, workspace, run, attempt, phase, sandbox, and current workspace fence resolved by the API. It fails closed when that authority is terminal, revoked, expired, replaced, or stale. It cannot create another run, perform state recovery, or authenticate to MCP.

The workspace dashboard exposes this surface as CLI access in the workspace section navigation, at /workspaces/{workspace_id}/cloud. StackShip first verifies the active workspace, project, organization slug, and credential:write; members without that permission see a quiet read-only notice instead of token controls. The page renders the exact native cloud block and can issue a workspace/project-scoped token for state migration or remote plan and apply. The service-account name and description sit behind an advanced disclosure with sensible defaults, the token scope line shows the project and workspace names with their identifiers as secondary detail, and the cloud block, tofu login, and automation command blocks each carry a copy control. Token plaintext is displayed once; after the operator acknowledges saving it, the page clears the plaintext, shows a transient “Token disclosure cleared” confirmation, and returns to the create-token form so the next token uses a fresh idempotent request.

The block uses the workspace’s unique cloud_name; the human-readable dashboard name is not sent to OpenTofu. The state-migration token includes state:outputs:read so ordinary tofu output works after publication while sensitive output values still require state:read.

State versions

Every committed state version records:

  • an immutable public id;
  • workspace and organization;
  • lineage and serial;
  • plaintext SHA-256 checksum;
  • plaintext byte size;
  • resource count;
  • creator and creation time; and
  • an encrypted R2 object reference.

PlanetScale stores metadata, not state JSON. R2 object keys use opaque identities rather than customer names.

Workspace state history uses opaque forward pagination. Read meta.page.next_cursor from a list response and send a non-null value as the next request’s cursor. State creation time and public state id form the stable descending key, including when multiple versions share a timestamp. Bare timestamps and delimiter cursors are invalid and return 400 STATE_VERSION_LIST_CURSOR_INVALID.

OpenTofu publishes managed state directly through the Cloud/TFE contract. The run token can read only its pinned predecessor and can create a successor only under the exact active managed-run reservation, attempt, sandbox, lock, and fence. Raw state streams through the Worker into its final encrypted R2 identity; finalization verifies its byte count, checksum, lineage, serial, predecessor, outputs, and fence before advancing the current pointer.

The runner does not download predecessor state, upload resulting state or outputs, run tofu output, or promote a second object copy. After a successful command it requests only bounded publication metadata. StackShip accepts that metadata when both the published upload and state-version row carry the exact managed-run principal and object identities, then binds the result digest to the state checksum. If the commit response is lost after lock release, an exact callback replay uses the persisted resulting-state pointer; it cannot present a different digest, state version, attempt, sandbox, predecessor, lock, or fence. An emergency-state artifact exists only when OpenTofu leaves errored.tfstate after the Cloud backend cannot persist a failed or interrupted write, or when the runner safely recovers OpenTofu’s last-resort state JSON from guarded human-view stderr or the JSON-view stdout emergency envelope. That fallback is spooled, classified, and extracted by bounded disk streams under the same exact 100_000_000-byte limit; raw state bytes never enter StackShip logs or Worker memory. The runner never captures a root terraform.tfstate as an ordinary resulting-state copy.

Every emergency candidate is untrusted sandbox evidence. Providers, provisioners, local-exec, and OpenTofu share the sandbox trust domain, so StackShip does not describe the file or guarded output as OpenTofu-attested or verified. Its digest and byte count identify the exact candidate shown to the operator. It never advances the current pointer automatically and can enter state history only through the explicit recovery flow with a reason, attestation, provider reconciliation, and predecessor, lineage, fence, and successor validation.

OpenTofu lineage values use the canonical lowercase, hyphenated 16-byte hex shape but are not required to carry an RFC UUID version or variant. StackShip preserves and validates that OpenTofu shape instead of rejecting valid random lineages as malformed UUIDs.

Locking and concurrency

Local cloud clients and managed runs use the same workspace writer reservation. A lock has an owner and a monotonically advancing fence. Stale owners cannot publish after the fence changes.

Managed-run access is phase-specific:

Attempt State read State lock State publication
Managed plan Current state Acquired and released for the OpenTofu command Never
Speculative plan Run-pinned state version only Never Never
Managed apply Current predecessor Bound to the active run reservation and attempt Only with the expected predecessor and current fence

Workspace run-capacity saturation receives an immediate JSON:API 409 state_conflict with guidance to finish or cancel an active run; this stable contention is not reported as a retryable service outage. When the configured capacity is zero, an administrator must increase it before a run can start. A separate writer-reservation conflict reports that another state operation owns the workspace. Organization-wide capacity exhaustion remains a temporary 503 because it can clear independently of the requested workspace.

The apply credential has no state:recover authority. Publication still requires the active managed writer reservation, an owned operation lock, and the exact attempt and fence; possession of the bearer value alone is insufficient.

State upload happens outside the database transaction. A pending version becomes current only after the encrypted object, size, checksum, lineage, serial, current predecessor, and lock fence are verified. A canceled, truncated, over-limit, or failed upload leaves the current pointer unchanged. Each upload capability is atomically claimed before the API pulls its body or touches R2. Concurrent or replayed callers therefore cannot overwrite or delete the winning upload. A capability that is expired, already claimed, or otherwise unresolved fails the product-admission check with the same generic retryable denial used for unavailable product scope. A request that loses the claim after admission receives 404; in both cases StackShip does not read the caller’s body.

Compatibility responses that can carry upload capabilities use Cache-Control: private, no-store. JSON:API errors retain the compatibility media type even when the successful form of the endpoint is a raw upload or download. Structured rejection logs record the matched route pattern, such as /capabilities/state-uploads/:capability, and never the literal capability-bearing request path.

Finalization is replay-safe. When concurrent readers finalize the same verified upload, the committed state version and its output handles win; newly allocated candidate handles are not treated as conflicting state evidence.

OpenTofu may publish apply checkpoints about every 20 seconds. StackShip accepts an exact contiguous managed-run chain of at most 1,024 versions, covering roughly 720 checkpoints in the four-hour runner authority window plus operational headroom. TF_STATE_PERSIST_INTERVAL is reserved and cannot be overridden by workspace variables. The chain lookup is indexed by the exact organization, workspace, lock, fence, serial, creation time, and public id.

Discarded uploads retain durable cleanup state. Raw, JSON, and outputs objects are deleted only after the database row is discarded, and cleanup_completed_at is recorded only after all three deletions succeed. Failures remain queryable for retry. Scheduled artifact maintenance processes a bounded batch of incomplete discarded uploads before ordinary R2 orphan reconciliation, so cleanup does not depend on the original client retrying. Each upload is isolated: a failed deletion is counted and retried later without blocking cleanup of the remaining batch.

Customer access

Authorized customers can:

  • inspect immutable state-version metadata in the workspace dashboard without receiving raw state;
  • download a selected version through the restricted native v1 API when their administrative role grants both raw-state permissions;
  • use tofu state pull and tofu state push through /api/v2; and
  • migrate with ordinary OpenTofu initialization and state commands.

The dashboard’s state history is a dense table: serial, creation time, resource count, size, truncated checksum with the full digest as a tooltip, and a Current badge on the active version. Selecting Download on a row opens one shared download panel bound to that version; it presents the restricted-download steps in order — verify identity, record an operator reason, then download.

The dashboard does not currently provide state diff, rollback, editing, retention, or resource-search controls, and the state history says so in one line near its header — rolling back means pulling and pushing state through the native cloud contract. The run recovery panel does provide a bounded comparison of current and emergency-state identities; it is not a general rollback control.

Raw state download is a restricted-artifact operation. The v1 route accepts only POST, requires both state:read and artifact:read_sensitive, a typed operator reason of at most 500 characters, and step-up reauthentication no older than 15 minutes. Ordinary organization members receive state:outputs:read, not state:read, and the dashboard does not render raw-state controls for them.

StackShip commits state.downloaded with the exact operator reason before releasing bytes, then streams authenticated decryption through the API and dashboard Worker. The reason is sent in a bounded form body, not the URL. The dashboard’s same-origin referrer policy keeps the form mutation’s Origin verifiable without disclosing a referrer to another origin. Every non-empty query string is rejected before state storage or audit access. If the audit commit fails, StackShip cancels the decrypted storage stream and releases no state bytes. StackShip never returns a plaintext R2 URL.

The service-account /api/v2 download path is a separate, non-browser OpenTofu trust path, but it keeps the same fail-closed storage guarantees. Before auditing or releasing bytes, StackShip requires the decrypted object’s plaintext byte count and SHA-256 digest to match the immutable state-version row. A mismatch or audit-write failure cancels the decrypted stream.

Reading state from other workspaces

By default no workspace can read another workspace’s state. Cross-workspace reads are ordinary RBAC assignments: a workspace is a subject that assumes a system role on another workspace, on every workspace in a project, or on the whole organization. Two roles exist:

  • Outputs consumer (state-outputs-consumer) — read the producer’s current non-sensitive output values. This is what the tfe_outputs data source needs.
  • State consumer (state-consumer) — additionally download the producer’s current raw state, including sensitive output values. This is what the terraform_remote_state data source needs, and it discloses every attribute of every resource in the state file — prefer the outputs role unless raw state is genuinely required.

Assignments are managed on the workspace’s Access tab (or the /v1/workspaces/{workspace_id}/access API) and require the organization admin role’s state:share permission. Subjects can be a specific workspace, all workspaces in a project (membership is evaluated live — a workspace that moves projects gains or loses coverage immediately), or all workspaces in the organization.

During a remote plan or apply, the injected run credential acts as its workspace and can therefore exercise these assignments — no extra tokens in variables. A consumer always reads the producer’s current state version: historical versions, pending uploads, locking, publication, and variables stay invisible, and unauthorized reads return 404 exactly like nonexistent resources. The assumed role never exceeds what the consuming credential itself may do, and every shared raw-state download is audited with the consuming workspace recorded.

The compatibility surface reports global-remote-state: true on a workspace when an organization-wide state-consumer assignment covers it; the TFC remote-state-consumers relationship endpoints are not implemented.

Interrupted apply recovery

An uncertain or interrupted apply retains its writer reservation until an authorized recovery transaction advances the fence. The dashboard action requires run:recover, state:recover, recent step-up, a typed reason, and an explicit fence confirmation. StackShip verifies the submitted current pointer under the lineage lock. An unchanged predecessor can be retained or advanced by an exact emergency successor. A moved pointer can be retained only when it is the exact published partial state of the interrupted run, with matching run, attempt, sandbox, predecessor, lock, fence, and object identities. Every other moved pointer fails with STATE_PREDECESSOR_CHANGED. The interrupted apply authorization and exact managed-run lock and reservation must remain active, and both recovery preparation and commit recheck those facts under the lineage lock. Durable uploaded or claimed-pending successors are finalized first. An expired pending upload is reclaimed automatically only when both raw and JSON claim timestamps are null.

An unresolved claimed upload is never discarded because of age or status alone. The dashboard shows the exact upload id and claim timestamp and warns that supersession may lose in-flight state. Submission first attempts to finalize the exact durable artifact. If it remains unavailable, recovery stays blocked until an operator confirms that exact pair. Commit rechecks it while holding the upload and lineage locks, advances the fence, and discards only that row in the same transaction. The audit event records the exact superseded id and claim timestamp.

A prepared recovery is leased to its operator for exactly 15 minutes according to PostgreSQL’s clock. Run review shows the authoritative expiry and a bounded remaining-time value. A takeover before expiry is rejected; after expiry it still requires both recovery permissions, recent step-up, unchanged evidence, and explicit takeover confirmation.

Downloading the separate emergency-state artifact is not the recovery mutation. It requires artifact:read_sensitive and state:recover; holding run:recover without state:recover does not grant access to state bytes.

Emergency state is decrypted and parsed as a bounded stream, then copied through envelope encryption without buffering the state body. The commit accepts it only as the exact lineage and serial successor, records a new immutable state version under the recovery fence, and never overwrites a newer version. A verified-current-state resolution advances the same fence without changing the state pointer. Both paths revoke the old runner authority and leave the next ordinary action as a new plan.

Idempotent response-loss replay is storage-independent once recovery is completed. A prepared emergency replay verifies every byte of its persisted state and required outputs through EOF before commit. Corrupt persisted metadata or content fails closed without reading the emergency source. A source copy is resumed only when the persisted target is genuinely absent.

Customer OpenTofu cloud credentials remain a separate non-browser trust path. A service-account token must be explicitly scoped to state:read; the /api/v2 compatibility flow is not authorized by a dashboard session or by the member role. Managed-run credentials are separate internal principals and do not inherit that customer’s permissions.

Streaming limit

State plaintext is limited to exactly 100_000_000 bytes. A known larger body is rejected before ingestion. An unknown-length stream is rejected as soon as its incremental counter observes byte 100_000_001.

Uploads flow through bounded encryption and digest calculation to R2. Downloads flow from R2 through bounded authenticated decryption to the client. Neither path concatenates, clones, or parses the complete state in Worker memory.

Sensitive outputs

The native compatibility surface distinguishes ordinary and sensitive state outputs. Access is authorized at the endpoint; a sensitive value is not made safe merely because its metadata is visible.

Treat every raw state download as sensitive. Store it outside the repository, limit access, and delete temporary copies after migration or investigation.

Migration

Local-to-cloud migration is covered by a real OpenTofu integration test that creates local state, adds the StackShip cloud block, initializes interactively, and proves that lineage, serial, and outputs arrive through the native contract.

See migrate state for the operator sequence.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close