July 3, 2026 • Roozbeh • 7 min reading time
Stop declaring your stack order manually
Stop declaring your stack order manually
Every platform team running infrastructure at scale hits the same wall. You have a handful of stacks that must be applied in a specific order: networking first, then the cluster, then the add-ons, then GitOps. Call it what you want — tiered infrastructure, layered stacks, dependency chains. The problem is the same.
The question is: who’s responsible for knowing that order, and what happens when a new engineer gets it wrong?
The way it works today
Take a realistic platform. Four stacks, linear dependencies:
networking → cluster → addons → gitopsnetworking provisions VPCs and subnets. cluster needs those subnet IDs to place the EKS cluster. addons needs the cluster endpoint and CA to install Helm charts against it. gitops needs the cluster credentials to register ArgoCD.
Each tool in the ecosystem has a different answer for how to express this.
Terraform: remote state + CI ordering
Terraform’s built-in answer is terraform_remote_state. In cluster/main.tf:
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "myorg-tf-state"
key = "networking/dev/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_eks_cluster" "this" {
name = "platform-one-${var.env}"
vpc_config {
subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
security_group_ids = []
}
}The state reference works. What doesn’t is CI. Terraform has no knowledge of which module runs first — that’s your pipeline’s job. In practice this becomes a needs: chain in GitHub Actions:
jobs:
networking:
uses: ./.github/workflows/tf-apply.yml
with: { dir: networking, env: dev }
cluster:
needs: networking
uses: ./.github/workflows/tf-apply.yml
with: { dir: cluster, env: dev }
addons:
needs: cluster
uses: ./.github/workflows/tf-apply.yml
with: { dir: addons, env: dev }
gitops:
needs: addons
uses: ./.github/workflows/tf-apply.yml
with: { dir: gitops, env: dev }The dependency graph now lives in three places: terraform_remote_state data sources, needs: in the pipeline, and the mental model of whoever wrote the pipeline. When you add a fourth stack, you update all three. When a new engineer adds a stack without updating the pipeline, a Friday afternoon deploy fails in a non-obvious way.
Terragrunt: explicit dependency {} blocks
Terragrunt improves on this with explicit dependency declarations:
# cluster/terragrunt.hcl
dependency "networking" {
config_path = "../networking"
mock_outputs = {
vpc_id = "vpc-00000000000000000"
private_subnet_ids = ["subnet-00000000000000000"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.networking.outputs.vpc_id
subnet_ids = dependency.networking.outputs.private_subnet_ids
}run-all apply reads these blocks and executes stacks in topological order. That’s genuinely better than raw Terraform — the dependency graph lives in one place, and run-all enforces it.
But notice mock_outputs. Because Terraform plans need variable values at plan-time, Terragrunt can’t read real outputs from an undeployed networking stack. You have to supply fake ones. Those mocks go stale. The real vpc_id is vpc-0a1b2c3d4e5f, the mock is vpc-00000000000000000, and the error messages in CI reference the mock, not the real thing. And you’re still maintaining the dependency {} blocks by hand.
Pulumi: StackReference without ordering
Pulumi’s answer is StackReference:
# cluster/__main__.py
import pulumi
networking = pulumi.StackReference("myorg/networking/dev")
vpc_id = networking.get_output("vpc_id")
subnet_ids = networking.get_output("private_subnet_ids")
cluster = aws.eks.Cluster("platform-one-dev",
vpc_config=aws.eks.ClusterVpcConfigArgs(
subnet_ids=subnet_ids,
),
)This is the cleanest API of the three — no mocks, no remote state boilerplate, typed outputs. But StackReference gives Pulumi no information about execution order. If you run pulumi up in cluster/ before networking/ has been applied, you get a runtime error that networking has no outputs yet. The ordering is again your CI’s problem.
The ubx approach: references are declarations
In ubx, cross-stack references use the @ prefix:
# cluster/cluster.iac
component "eks" {
source = "../components/eks"
cluster_name = "platform-one-${input.env}"
cluster_version = input.cluster_version
vpc_id = @networking.vpc_id
subnet_ids = @networking.private_subnet_ids
instance_type = input.node_instance_type
desired_capacity = input.node_count
}# addons/addons.iac
component "addons" {
source = "../components/eks-addons"
cluster_name = @cluster.cluster_name
cluster_endpoint = @cluster.cluster_endpoint
cluster_ca = @cluster.cluster_ca
vpc_id = @cluster.vpc_id
install_ingress = input.install_ingress
install_cert_manager = input.install_cert_manager
install_ext_secrets = input.install_ext_secrets
}There’s no separate dependency declaration. @networking.vpc_id in cluster.iac is already the declaration. The compiler reads it, builds the dependency graph, and validates it at compile time — before a cloud credential is used.
$ ubx validate --all --env dev
✓ networking valid
✓ cluster valid (reads: networking.vpc_id, networking.private_subnet_ids)
✓ addons valid (reads: cluster.cluster_name, cluster.cluster_endpoint, cluster.cluster_ca, cluster.vpc_id)
✓ gitops validIf you reference an output that doesn’t exist, ubx validate fails with a compile error:
✗ cluster ubx.iac:7 @networking.private_subnet_ids: output "private_subnet_ids" is not exported by stack "networking"No mock outputs. No deploy cycle. No CI failure that manifests at 3am.
What the plan looks like
When you run ubx plan --all --env dev, the compiler has already resolved the full dependency graph. The output reflects it:
$ ubx plan --all --env dev
Resolving dependency graph...
networking → cluster → addons
↘ gitops
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Stack networking / env: dev
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ aws:ec2:Vpc platform-one-dev
+ aws:ec2:Subnet platform-one-dev-public-1
+ aws:ec2:Subnet platform-one-dev-public-2
+ aws:ec2:Subnet platform-one-dev-private-1
+ aws:ec2:Subnet platform-one-dev-private-2
+ aws:ec2:InternetGateway platform-one-dev-igw
+ aws:ec2:NatGateway platform-one-dev-nat
Plan: 7 to add, 0 to change, 0 to destroy
Outputs: vpc_id (pending), private_subnet_ids (pending), public_subnet_ids (pending)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Stack cluster / env: dev
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ aws:eks:Cluster platform-one-dev
+ aws:eks:NodeGroup platform-one-dev-ng
+ aws:iam:Role platform-one-dev-eks-role
+ aws:iam:Role platform-one-dev-node-role
Plan: 4 to add, 0 to change, 0 to destroy
Outputs: cluster_name (known), cluster_endpoint (pending), cluster_ca (pending)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Stack addons / env: dev
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ kubernetes:helm:Release aws-load-balancer-controller
+ kubernetes:helm:Release cert-manager
+ kubernetes:helm:Release external-secrets
Plan: 3 to add, 0 to change, 0 to destroy
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Stack gitops / env: dev
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ kubernetes:helm:Release argocd
Plan: 1 to add, 0 to change, 0 to destroy
Outputs: argocd_server_url (pending)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Summary 4 stacks / env: dev
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total: 15 to add, 0 to change, 0 to destroy
Apply with: ubx apply --all --env devThe (pending) annotation on outputs is the Pending<T> type system at work. vpc_id isn’t known until networking is applied, so cluster can’t see its real value during plan. ubx tracks the dependency as a type — Pending<string> — and validates that you’ve used it correctly everywhere it flows, without needing to materialize the value.
The --all flag
ubx apply --all --env dev does exactly what you’d expect: applies all four stacks in dependency order, in one command, with no pipeline orchestration required.
$ ubx apply --all --env dev
networking applying... ✓ 7 created (2m 14s)
cluster applying... ✓ 4 created (4m 51s)
addons applying... ✓ 3 created (1m 37s)
gitops applying... ✓ 1 created (0m 48s)
Done. 15 resources created across 4 stacks.Stacks that don’t depend on each other run in parallel. You can tune concurrency with --parallel N. The ordering that took a CI pipeline to express before is now a compile-time property of your .iac files.
How they compare
| Terraform | Terragrunt | Pulumi | ubx | |
|---|---|---|---|---|
| Dependency declaration | None | dependency {} blocks | None | @stack.output refs |
| Ordering enforcement | CI pipeline | run-all | CI pipeline | Compiler + --all |
| Missing output detection | Runtime error | Runtime error | Runtime error | Compile error |
| Mock outputs needed | No | Yes | No | No |
| Where graph lives | CI + data sources | terragrunt.hcl | CI + code | Code only |
The key column is “where graph lives.” With Terraform and Pulumi, the dependency graph is split between your code and your CI pipeline — two representations that can drift. With Terragrunt, it’s consolidated into terragrunt.hcl files, which is better, but it’s still a manual declaration separate from the actual usage.
With ubx, the graph is implicit in the references. @networking.vpc_id in cluster.iac is the declaration. There’s nothing else to maintain.
Why compile-time matters
There’s a pattern worth naming here. Terraform, Terragrunt, and Pulumi are all tools that run code. They discover dependencies by executing something — a plan, a run-all, a Python import. ubx is a tool that understands code. It reads your references before execution and can answer questions about them.
That distinction matters in practice. A tool that runs code to discover dependencies can only tell you about dependency errors after it’s already started trying to apply something. A tool that understands code can tell you at ubx validate time, in CI, on your laptop, with no cloud credentials.
When a new engineer joins your team and adds a fifth stack, they write @cluster.cluster_endpoint in their .iac file. They don’t need to know the deployment order. They don’t need to update the pipeline. The compiler knows.
Get started
The multi-stack tutorial walks through building the networking → cluster → addons dependency chain from scratch, including how @ references are validated and how ubx plan --all handles partial state.
Cross-stack reference syntax is documented in the language reference.
Platform-one, the reference repository used in this post, is available at github.com/ubiquex/platform-one.