Skip to content
GantryCD

Cross-Stack Outputs

gantrycd_state_outputs lets one stack read another stack’s Terraform outputs through the gantrycd API — a mediated, RBAC-gated replacement for terraform_remote_state. The consumer knows only the gantrycd endpoint and holds a token with (stack, read_data) on the producer; the backend reads the producer’s state server-side, so no S3 backend or its credentials are ever exposed to the caller.

Using it

The producer needs no extra code — its normal output blocks are enough. The consumer adds the data source (in the terraform-provider-gantrycd provider):

data "gantrycd_state_outputs" "networking" {
  stack_id = "aws-vpc-dev-euw1"
}

# Reference a value exactly like a remote_state output:
#   data.gantrycd_state_outputs.networking.outputs.vpc_id

Inside a deployment this works with zero provider configuration: the runner already exports the run’s service-account credential as GANTRYCD_PAT / GANTRYCD_ORG_ID / GANTRYCD_API_URL. An operator only has to grant that account read access to the producer (below).

Attributes: outputs (a typed object of every readable output), sensitive_output_names (names the producer marked sensitive), and source_run_id (the run whose applied state the values came from).

The lookup is confined to the provider’s configured organization; a stack in another org cannot be read.

Permissions

Reading outputs is gated by a dedicated (stack, read_data) action on the producer stack — distinct from (stack, read), so a consumer can be granted just a producer’s outputs without gaining broad read on it. It’s a normal stack-level check like every other stack route: (stack, read_data, "networking") (or a glob) lets the caller read networking’s outputs.

admin and stack-admin hold it through their (stack, *) wildcard. It is deliberately not part of the stack-reader role: read_data returns sensitive output values unmasked (see below), a strictly higher privilege than the masked outputs a reader sees in the Outputs tab through (stack, read). Grant it explicitly — to a consuming stack’s identity, or a purpose-built custom role — rather than bundling it into broad read-only access. A caller without the grant gets a 404 (indistinguishable from a missing stack, so the endpoint isn’t a name-enumeration oracle).

Sensitive outputs

The cross-stack API is a drop-in: sensitive output values are returned plaintext (over TLS, gated by read_data), and sensitive_output_names flags which ones so a consumer can wrap them with sensitive(). The plugin framework has no clean per-key dynamic sensitivity, so the values aren’t auto-marked — this is the one deviation from terraform_remote_state.

The stack Outputs tab takes the opposite stance: it masks sensitive values (replaced by a placeholder at the runner, badged in the UI), like resource attributes — secrets never render in the console, and a masked output has no copy button, so the placeholder can’t be mistaken for the real value. The two surfaces are fed from different sources: the API reads the raw state on demand; the tab reads the masked run artifact.

Don’t put secrets in outputs. Marking an output sensitive only affects display — the value is stored in plaintext in the tfstate regardless, and this API returns it unmasked and caches it in plaintext in Redis (see below) for up to the 24-hour TTL. So a “sensitive” output is a plaintext secret sitting in object storage and the backend cache, gated only by read_data, not by encryption. This is a bad practice, not a control this feature protects.

Instead, keep secrets in a dedicated secret manager (e.g. AWS Secrets Manager, Vault) and expose only a reference — a secret ARN, name, or version — as an output. The consumer resolves the reference through its own provider (e.g. a data "aws_secretsmanager_secret_version"), so the secret is fetched at apply time with its own IAM/audit and never lands in either stack’s state, this API, or Redis. sensitive_output_names still lets you flag references you’d rather not show in plan output.

Caching (no object storage on a hit)

The API serves reads from Redis under a key that encodes the state-bearing run id (stack:outputs:<org>:<stack>:<runID>), so there is no active invalidation — a new apply produces a new key and the stale entry is simply never consulted again. A cache hit costs zero object-storage requests; the state object is read and parsed only the first time after a new apply. A 24-hour TTL bounds memory.

The run id is LatestAuthoritativeRun(AuthorityLiveState) — a read epoch, not authorship: the latest lane-holding run that touched state (deployment mode StateBearingDeploymentModes: plan/destroy/refresh — any origin), which includes an apply that FAILED part-way. Including it is what rotates the key after a partial apply; while the authority was success-only, a failed apply left the key pinned to the pre-apply run and other stacks kept reading pre-apply outputs for the whole TTL. The served bytes always come from a live read of the state object at fill time, so the id only versions the cache; what last changed the state is a separate question that can be answered by nothing (a runless overwrite) or by something outside GantryCD entirely. There is one tfstate per stack, so a promoted-PR or dependency apply mutates it too; an origin filter would silently serve pre-promote outputs. (A runless change — a state-administration overwrite/restore — has no new run, so it is reflected at the next successful lane-holding run of any mode — the plan an operator naturally runs next rotates the key — or at TTL lapse, whichever comes first.) Requires a gantrycd-managed backend; a BYO backend’s state lives where the backend can’t reach it.

The dependency is NOT automatic

Reading a producer’s outputs does not create a dependency. It once did; that inference was withdrawn. gantrycd does infer a dependency when a stack reads a cloud object another stack manages — but an output is not a cloud object, it is a value this backend served, so nothing here can be matched. Declare it with a gantrycd:dependency comment naming the producer stack.

stack_id being a literal in your own configuration establishes which stack is named — it does not establish which gantrycd backend answered the lookup. The provider lets any stack point at any endpoint, and the endpoint is not recorded in state. Two installations sharing a database lineage — a staging clone, or the far side of a migration — hold the same organization, stack and run IDs, so reading the other one’s outputs produced an edge that looked local and was wrong. Since a wrong edge feeds cycle detection, it could demote valid automation.

The rule was previously described here as exact. It was not, and the edge is no longer created. See Inferred dependencies for what gantrycd does infer, why an ARN cannot fail this way, and what bringing output discovery back would require.

Declaring the dependency

Write a gantrycd:dependency comment naming the producer:

# gantrycd:dependency: from_stack=aws-vpc-dev-euw1 from_resource=output.vpc_id trigger=always

That gives you a resource-scoped edge whose changeset-aware trigger fires only when the producer’s output.vpc_id actually changes — narrower than the whole-stack edge the withdrawn inference would have produced, and with a trigger policy no inference is allowed to choose (every inferred edge is manual).