Spacelift
Spacelift can drive Stategraph instead of plain Terraform: runs are still triggered, approved, and audited in Spacelift, but plans and applies execute through the Stategraph CLI, so state lives in Stategraph — scoped plans, per-resource conflict detection, and SQL over your estate included. No resources move; adoption is an import.
Prerequisites
- Stategraph running and reachable from your Spacelift workers (see Deployment)
- A Stategraph API key and tenant (see Setup)
- Your Terraform repository attached to Spacelift through a VCS integration
Import your state
On a machine with your repository checked out, import the existing state once:
terraform state pull > terraform.tfstate
stategraph import tf --name mystack --tenant $STATEGRAPH_TENANT_ID --workspace default terraform.tfstate
Commit the generated stategraph.json — the CLI locates the state through it. Note the state id from stategraph states list; the workflow below uses it.
If your configuration carries a backend block, remove it: Stategraph is the backend now, and Spacelift is told not to manage state either.
Add the custom workflow
Spacelift's custom workflow tool replaces its built-in Terraform with commands you define. Add .spacelift/workflow.yml at the repository root:
init: /tmp/stategraph info
workspaceSelect: 'echo "workspace {{ .WorkspaceName }}"'
workspaceNew: 'echo "workspace {{ .WorkspaceName }}"'
plan: /tmp/stategraph plan --out "{{ .PlanFileName }}"
showPlan: /tmp/stategraph tf show --json "{{ .PlanFileName }}"
showState: sh -c '/tmp/stategraph states export -q --state "$STATEGRAPH_STATE_ID" 2>/dev/null | python3 ../.spacelift/showstate.py'
getOutputs: sh -c '/tmp/stategraph states export -q --state "$STATEGRAPH_STATE_ID" 2>/dev/null | python3 -c "import json,sys;print(json.dumps(json.load(sys.stdin).get(\"outputs\",{})))"'
apply: /tmp/stategraph apply "{{ .PlanFileName }}"
destroy: 'echo "destroy through Stategraph is not supported yet" && exit 1'
stategraph tf show --json emits a Terraform-compatible JSON plan, so Spacelift's diff view, change counts, and plan policy inputs keep working. The state export carries outputs in terraform output -json shape, which is what getOutputs extracts (jq .outputs works too if you prefer it — both tools ship on the default runner). A plan file is written even when there are no changes, so idempotent runs finish cleanly.
showState feeds Spacelift's post-apply managed-resources upload, which parses stdout strictly as the terraform show -json values representation — raw state JSON is rejected as "non-JSON output". Commit this transform as .spacelift/showstate.py (the ../ in the command above assumes your project root is one level below the repository root; adjust if not):
# Transform a raw tfstate (stategraph states export) into the
# `terraform show -json` values representation Spacelift's post-apply
# managed-resources upload expects.
import json
import re
import sys
state = json.load(sys.stdin)
resources = []
for r in state.get("resources", []):
for inst in r.get("instances", []):
addr = f'{r["type"]}.{r["name"]}'
if r.get("mode") == "data":
addr = "data." + addr
if r.get("module"):
addr = f'{r["module"]}.{addr}'
if inst.get("index_key") is not None:
addr += f'[{json.dumps(inst["index_key"])}]'
m = re.search(r'provider\["([^"]+)"\]', r.get("provider", ""))
resources.append({
"address": addr,
"mode": r.get("mode", "managed"),
"type": r["type"],
"name": r["name"],
"provider_name": m.group(1) if m else r.get("provider", ""),
"schema_version": inst.get("schema_version", 0),
"values": inst.get("attributes", {}),
})
doc = {
"format_version": "1.0",
"terraform_version": state.get("terraform_version", "1.0.0"),
"values": {"root_module": {"resources": resources}},
}
print(json.dumps(doc))
With the transform in place, Spacelift's Resources view lists the resources Stategraph manages after every apply.
Create the stack
Create the stack with the Terraform workflow tool set to Custom, and:
- Manage State off — settable only at stack creation. State lives in Stategraph.
- The
nobackendlabel — Spacelift otherwise fails initialization looking for a local state backend. - Project root pointing at your Terraform directory if it is not the repository root.
Add a before_init hook (and the same as before_apply) that installs the CLI and an OpenTofu binary on the worker:
curl -fsSL https://example.com/stategraph-linux-amd64.tar.gz -o /tmp/sg.tgz && \
tar xzf /tmp/sg.tgz -C /tmp stategraph && chmod +x /tmp/stategraph && \
curl -fsSL https://github.com/opentofu/opentofu/releases/download/v1.8.8/tofu_1.8.8_linux_amd64.tar.gz -o /tmp/tofu.tgz && \
mkdir -p /tmp/tofubin && tar xzf /tmp/tofu.tgz -C /tmp/tofubin tofu && chmod +x /tmp/tofubin/tofu
Alternatively, bake both binaries into a custom runner image and skip the hooks.
Then set the stack's environment variables:
| Variable | Value |
|---|---|
STATEGRAPH_API_BASE |
Your Stategraph server URL |
STATEGRAPH_API_KEY |
The API key (mark it write-only) |
STATEGRAPH_TENANT_ID |
Your tenant id |
STATEGRAPH_STATE_ID |
The state id from stategraph states list |
TF_CMD |
/tmp/tofubin/tofu |
TF_CMD points the CLI at the OpenTofu binary the hook installed; the CLI shells out to it for planning.
Run it
Trigger a run. The plan phase runs stategraph plan, Spacelift renders the diff from the JSON plan and holds at Unconfirmed as usual; confirming applies through stategraph apply, and the transaction — plan, apply, and who confirmed it — is visible in both Spacelift's run view and Stategraph's own transaction log.
Runs that touch disjoint resources no longer queue behind each other's state lock: conflicts are detected per resource at commit, not per state file.
Pull request flow
Proposed runs work unchanged — this is the Atlantis-style loop, with Stategraph doing the planning:
- A pull request against the tracked branch triggers a proposed run:
stategraph planexecutes on the worker and the result lands on the PR as a commit check (spacelift/<stack>). The check summary shows the change counts and a resource-level diff computed from the Stategraph plan, and links to the full plan output in Spacelift. - Merging the PR triggers a tracked run, which plans again and holds at Unconfirmed (or applies immediately with autodeploy on).
- Confirming applies through
stategraph apply; the transaction is recorded in Stategraph, and stack outputs flow into Spacelift's outputs view viagetOutputs.
Spacelift posts checks, not PR comments, by default; add a notification policy if you want the plan summarized as a comment.
Limitations
- Destroy runs are not supported through the CLI yet; run destroys locally and re-import, or keep destroy plans out of Spacelift.
- Spacelift's scheduled drift detection is gated to plan tiers that include it; on tiers without it the schedule is refused ("Drift detection and advanced scheduling are not supported by your plan"). Mechanically it is a scheduled proposed run of the workflow's
plancommand, which this setup already serves. Stategraph also provides drift detection natively, independent of Spacelift's tier.
Next Steps
- Transactions — what a plan/apply commit looks like inside Stategraph
- Multi-state operations — atomic changes across states
- Deployment — running the Stategraph server your workers talk to