Stack Integration
Dividing infrastructure across multiple stacks makes each one safer to modify independently - but stacks rarely exist in isolation. A networking stack creates VPCs and subnets; a compute stack needs to know where those subnets are. This page covers the patterns for wiring stacks together without creating the very monolith you were trying to escape.
Provider and Consumer Stacks
Section titled “Provider and Consumer Stacks”Every cross-stack dependency follows the same basic shape:
- A provider stack creates and manages shared resources (VPCs, subnets, DNS zones, databases)
- A consumer stack needs to reference those resources (a container cluster that must be placed in a specific subnet)
The problem is not the dependency itself - it is how the consumer discovers what it needs.
The Coupling Problem
Section titled “The Coupling Problem”The most common mistake is hardcoding the dependency inside the consumer stack:
# Fragile - hardcodes exact resource names from the networking stackvariable "subnet_ids" { default = ["cluster_subnet_0", "cluster_subnet_1"]}This creates tight coupling with three consequences:
| Consequence | What it means in practice |
|---|---|
| Fragile deployments | If the networking team renames their subnets, the consumer breaks - and they may not know it until your next deployment |
| Cannot test in isolation | You can’t run the consumer stack in a dev environment without a full production networking stack alongside it |
| Distributed monolith | The infrastructure is nominally split into stacks but can only be developed, tested, and deployed as one unit - defeating the purpose entirely |
The goal is loose coupling: the consumer stack declares what it needs as a parameter; something outside the stack figures out where to find it.
Resource Discovery Patterns
Section titled “Resource Discovery Patterns”Rather than hardcoding values, consumer stacks can dynamically discover what they need. Three patterns are available, each with different portability and coupling characteristics.
1 · Resource Matching
Section titled “1 · Resource Matching”The consumer uses the cloud provider’s API to search for resources by tag or naming convention - no knowledge of how the provider stack was built.
# Terraform example - find subnets by tag rather than hardcoded namedata "aws_subnets" "cluster" { filter { name = "tag:network_tier" values = ["compute_cluster"] }}| Advantage | Tool-agnostic - provider and consumer can use entirely different IaC tools |
| Advantage | No hardcoded names; works across any number of matched resources |
| Risk | The tag/naming convention becomes a strict contract. If the provider team changes it, the consumer silently breaks |
2 · Stack State Lookup
Section titled “2 · Stack State Lookup”The consumer reads values directly from the provider stack’s remote state file. The provider must explicitly output any value it wants to share.
# Terraform - read outputs from another project's statedata "terraform_remote_state" "networking" { backend = "s3" config = { bucket = "my-tf-state" key = "networking/terraform.tfstate" region = "us-east-1" }}
# Use a retrieved valuesubnet_ids = data.terraform_remote_state.networking.outputs.cluster_subnet_ids| Advantage | Dependencies are explicit - the provider must declare outputs; no hidden coupling |
| Advantage | Easy to set up with native tooling |
| Risk | Locks both stacks to the same IaC tool |
| Risk | State format changes between tool versions can require coordinated upgrades across all dependent stacks |
State access security: When you grant a project access to another project’s state file, it gains access to all attributes in that state - including sensitive values. Prefer the narrowest possible access.
Alternatives to consider before reaching for terraform_remote_state:
- Provider data sources - query the cloud API directly for the resource you need (e.g., look up a VPC by tag). More portable, no state access required.
- Input variables - pass values in manually or via a script. Safest option; requires explicit maintenance to keep values current.
Structuring remote state calls cleanly:
- Keep
terraform_remote_statecalls in the root module only - pass retrieved values down as input variables to child modules, keeping those modules reusable and unaware of the remote dependency - For frequently shared data, create a dedicated shared module that encapsulates the backend config; other teams import the module rather than writing the backend config themselves
3 · Integration Registry Lookup
Section titled “3 · Integration Registry Lookup”The provider stack publishes the values it creates to a central key-value registry. The consumer queries the registry at deploy time - neither stack needs to know how the other is implemented.
Provider stack apply └─► creates subnets └─► publishes /infrastructure/production/cluster_subnet_ids → ["subnet-abc", "subnet-def"] │Consumer stack apply │ └─► reads /infrastructure/production/cluster_subnet_ids ◄────────────┘Hierarchical key namespaces keep the registry organised and readable:
/infrastructure/{environment}/{resource-type}/{name}| Advantage | Fully tool-agnostic - different teams can use different IaC tools with no coordination |
| Advantage | Explicit contract; easier to audit than state file access |
| Advantage | Simplifies future tool migrations - change the publisher, not the consumers |
| Risk | The registry becomes a critical dependency; if it goes down, provisioning is blocked |
If you already have a configuration registry for stack parameters, that same registry can usually serve for integration data too.
Implementing Discovery: In-Code vs. Dependency Injection
Section titled “Implementing Discovery: In-Code vs. Dependency Injection”Knowing the three discovery patterns is only half the picture. You also need to decide where in your system the discovery logic lives.
Discovery inside stack code (distributed monolith risk)
Section titled “Discovery inside stack code (distributed monolith risk)”Most IaC tools let you write lookup logic directly in the stack:
# Tempting, but problematicdata "aws_subnets" "cluster" { filter { name = "tag:network_tier" values = ["compute_cluster"] }}This introduces two problems:
- Code clutter - lookup logic that is irrelevant to the stack’s core purpose makes the code harder to read
- Hardwired deployment context - the stack is now tied to a specific discovery mechanism. Changing from resource matching to registry lookup means changing the stack itself
The result is a distributed monolith: stacks that appear modular but can only be tested and deployed together because each one hardwires its understanding of how its dependencies are managed.
Dependency Injection (preferred)
Section titled “Dependency Injection (preferred)”Dependency Injection (DI) removes discovery logic from the stack entirely. The stack declares what it needs as parameters; something outside the stack resolves those values and injects them.
# The stack just declares what it needs - no discovery logic insidevariable "cluster_subnet_ids" { type = list(string) description = "IDs of subnets where cluster nodes will be placed"}The consumer is now completely portable - it can run in production, staging, or a lightweight local test environment, because the caller decides what values to inject.
Injecting via Deployment Scripts
Section titled “Injecting via Deployment Scripts”A deployment script fetches values from whichever discovery mechanism the environment uses and passes them as parameters:
#!/usr/bin/env bash# Fetch subnet IDs from provider stack's remote stateSUBNET_IDS=$(terraform -chdir=networking output -json cluster_subnet_ids)
# Inject them into the consumer stackterraform -chdir=compute apply \ -var "cluster_subnet_ids=${SUBNET_IDS}"For a test environment, the same consumer stack gets lightweight fixture values:
# Local test - inject values from a small fixture stack, not full production networkingSUBNET_IDS='["test-subnet-1"]'terraform -chdir=compute apply -var "cluster_subnet_ids=${SUBNET_IDS}"Injecting via Infrastructure Compositions
Section titled “Injecting via Infrastructure Compositions”A composition is a declarative alternative to orchestration scripts - typically a YAML file that maps provider stack outputs directly to consumer stack inputs:
# composition.yaml (conceptual)stacks: - name: networking module: ./networking outputs: - cluster_subnet_ids
- name: compute module: ./compute inputs: cluster_subnet_ids: "${stacks.networking.outputs.cluster_subnet_ids}"A specialised tool reads the composition and runs the stacks in the correct order, wiring them together automatically.
Testing advantage: Swap out the heavy provider stack for a fixture in a test composition - the consumer stack code is identical in both cases:
stacks: - name: networking-fixture module: ./test-fixtures/networking outputs: - cluster_subnet_ids
- name: compute module: ./compute inputs: cluster_subnet_ids: "${stacks.networking-fixture.outputs.cluster_subnet_ids}"Choosing the Right Approach
Section titled “Choosing the Right Approach”flowchart TD
A["Need cross-stack data?"] --> B{"Teams use\nsame IaC tool?"}
B -- Yes --> C{"Need explicit\noutput contract?"}
B -- No --> D["Integration Registry\nor Resource Matching"]
C -- Yes --> E["Stack State Lookup\n(terraform_remote_state)"]
C -- No --> F["Resource Matching\n(cloud API query)"]
E --> G["Inject via DI\n(script or composition)"]
F --> G
D --> G
| Pattern | Tool coupling | Teams | Preferred when |
|---|---|---|---|
| Resource Matching | None | Any | Provider team controls stable tags; multi-tool environments |
| Stack State Lookup | Same tool required | Same team / toolchain | Explicit contracts matter; already using centralised state backend |
| Integration Registry | None | Different teams / tools | Large orgs, mixed toolchains, audit requirements |
| Hardcoded values | N/A | - | Never in production |
Regardless of which discovery pattern you choose, always implement it via Dependency Injection - keep discovery logic outside the stack code, inject values as parameters, and maintain the ability to test stacks in isolation.