Skip to content

July 3, 2026 • Roozbeh • 7 min reading time

Stop declaring your stack order manually

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 → gitops

networking 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       valid

If 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 dev

The (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

TerraformTerragruntPulumiubx
Dependency declarationNonedependency {} blocksNone@stack.output refs
Ordering enforcementCI pipelinerun-allCI pipelineCompiler + --all
Missing output detectionRuntime errorRuntime errorRuntime errorCompile error
Mock outputs neededNoYesNoNo
Where graph livesCI + data sourcesterragrunt.hclCI + codeCode 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.