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:dependencycomment 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).