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) athttps://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.runAutomation 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 pullandtofu state pushthrough/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 thetfe_outputsdata source needs. - State consumer (
state-consumer) — additionally download the producer’s current raw state, including sensitive output values. This is what theterraform_remote_statedata 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.