Skip to content

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.


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 most common mistake is hardcoding the dependency inside the consumer stack:

# Fragile - hardcodes exact resource names from the networking stack
variable "subnet_ids" {
default = ["cluster_subnet_0", "cluster_subnet_1"]
}

This creates tight coupling with three consequences:

ConsequenceWhat it means in practice
Fragile deploymentsIf the networking team renames their subnets, the consumer breaks - and they may not know it until your next deployment
Cannot test in isolationYou can’t run the consumer stack in a dev environment without a full production networking stack alongside it
Distributed monolithThe 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.


Rather than hardcoding values, consumer stacks can dynamically discover what they need. Three patterns are available, each with different portability and coupling characteristics.

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 name
data "aws_subnets" "cluster" {
filter {
name = "tag:network_tier"
values = ["compute_cluster"]
}
}
AdvantageTool-agnostic - provider and consumer can use entirely different IaC tools
AdvantageNo hardcoded names; works across any number of matched resources
RiskThe tag/naming convention becomes a strict contract. If the provider team changes it, the consumer silently breaks

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 state
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "my-tf-state"
key = "networking/terraform.tfstate"
region = "us-east-1"
}
}
# Use a retrieved value
subnet_ids = data.terraform_remote_state.networking.outputs.cluster_subnet_ids
AdvantageDependencies are explicit - the provider must declare outputs; no hidden coupling
AdvantageEasy to set up with native tooling
RiskLocks both stacks to the same IaC tool
RiskState 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_state calls 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

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}
AdvantageFully tool-agnostic - different teams can use different IaC tools with no coordination
AdvantageExplicit contract; easier to audit than state file access
AdvantageSimplifies future tool migrations - change the publisher, not the consumers
RiskThe 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 problematic
data "aws_subnets" "cluster" {
filter {
name = "tag:network_tier"
values = ["compute_cluster"]
}
}

This introduces two problems:

  1. Code clutter - lookup logic that is irrelevant to the stack’s core purpose makes the code harder to read
  2. 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 (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 inside
variable "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.


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 state
SUBNET_IDS=$(terraform -chdir=networking output -json cluster_subnet_ids)
# Inject them into the consumer stack
terraform -chdir=compute apply \
-var "cluster_subnet_ids=${SUBNET_IDS}"

For a test environment, the same consumer stack gets lightweight fixture values:

Terminal window
# Local test - inject values from a small fixture stack, not full production networking
SUBNET_IDS='["test-subnet-1"]'
terraform -chdir=compute apply -var "cluster_subnet_ids=${SUBNET_IDS}"

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:

test-composition.yaml
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}"

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
PatternTool couplingTeamsPreferred when
Resource MatchingNoneAnyProvider team controls stable tags; multi-tool environments
Stack State LookupSame tool requiredSame team / toolchainExplicit contracts matter; already using centralised state backend
Integration RegistryNoneDifferent teams / toolsLarge orgs, mixed toolchains, audit requirements
Hardcoded valuesN/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.