The goal is to create a simple project demonstrating Terragrunt deploying two Terraform modules: one module's output is used to initialize the provider in a second (dependent) module. This requires the first module to fully deploy before the provider initialization is invoked in the second module.
Key characteristics:
- Two pure Terraform modules:
foo-moduleandbar-module. - The
foo-moduletakes one input, calculates a random number, writes the result to a file, reads the file and returns the result as an outout. Therefore the output cannot be consumed unless the module has been deployed (the output file exists.) - The
bar-moduletakes one input, writes it to a file verbatim, reads the file and returns the result as an output. - If the output of
foo-moduleis passed to thebar-modulethen both module outputs should match afterapply. - In addition, the
bar-modulehas a provider (more on that below) whose config takes an argument that is initialized from abar-moduleinput variable. The goal is to model a common pattern where a resource (e.g. EKS cluster) is created in a one module, and then that modules's output(s) (e.g. EKS cluster endpoint, etc.) are used to initialize the Helm provider of another module to install a chart into the eks cluster.
The catch is - we want to use one single top-level command: terragrunt run --all -- apply.
This project was built using the following versions:
| Component | Version |
|---|---|
| OS | Ubuntu 24.04.4 LTS (amd64) |
| Go | 1.26.6 (for the custom provider - more below) |
| Terragrunt | v1.0.5 |
| OpenTofu | v1.12.6 |
Follow these steps:
- Build the provider, edit
provider-mirror.tfrcin the repo root so it's valid for your filesystem, and exportTF_CLI_CONFIG_FILEto point to the edited.tfrcfile. All these details are in the Provider section below. - Then deploy either
delaware, ormaryland. E.g.:export TF_CLI_CONFIG_FILE=$PWD/provider-mirror.tfrc cd delaware terragrunt run --all --non-interactive -- apply
Observe that the foo-module is deployed first, and then the bar-module is deployed with its custom provider initialized by an output of the foo-module. This proves the requirement that the dependent module (bar-module) can be deployed by a single terragrunt run --all -- apply and Terragrunt will orchestrate the dependencies between the foo-module and the bar-module.
The project has a very simple provider providers/terraform-provider-clusterconn. This provider supports a configuration block argument. It also has a simple resource but - doesn't do much more. Its sole purpose is to be included in the bar-module to verify that the provider can be initialized using a bar-module input variable that came from the foo-module output.
The module is a tiny, hand-written, unpublished Terraform/OpenTofu provider used only within this project. It stands in for a real provider like helm, whose provider block needs a value (cluster endpoint, auth token, etc.) that only exists after another module's resources have been applied.
It is a real provider built with the terraform-plugin-framework - not a mock or a stub. It just isn't published anywhere, so it's wired up via Terraform/OpenTofu's filesystem mirror mechanism instead of hosted in a registry.
provider "clusterconn" {
input_val = <any integer>
}| Argument | Type | Required | Description |
|---|---|---|---|
input_val |
number (int64) |
yes | Stand-in for a meaningful value. Just a number. |
This exists only to make the provider used (more below) by Terraform.
resource "clusterconn_testme" "bar" {
# empty
}| Attribute | Type | Required/Computed | Description |
|---|---|---|---|
id |
string | computed | Always "testme". |
input_val |
number (int64) | computed | Echoes back whatever the provider was configured with. |
This resource exists to verify that the provider Configure() function actually ran. Terraform/OpenTofu does not call a provider's Configure method at all if the configuration doesn't actually use that provider anywhere (no resource, no data source). A provider block that nothing references is still parsed and schema-validated - so, e.g., passing the wrong type for input_val would still be caught - but the plugin's own Configure logic never executes, and plan/apply succeed regardless of whether the value would have been usable by real code.
clusterconn_testme has no arguments of its own; it exists purely so the provider
is genuinely used, which makes Terraform actually call Configure(). There are two
independent ways to confirm it actually ran with the real value, both verified directly
against this project:
1. State attribute
clusterconn_testme's input_val attribute echoes back what the provider received:
terragrunt state show clusterconn_testme.bar2. TF_LOG=DEBUG log line
The Configure() function calls:
tflog.Debug(ctx, fmt.Sprintf("ZZZZZZZZZZZZZZZZZ var.input_val = %d", inputVal))So tflog.Debug only surfaces at TF_LOG=DEBUG or more verbose; it's silently dropped otherwise. The "ZZZZZ" part of the message was made to be easier to find in the verbose Terraform log output.
Recommended
TF_LOG_PATH with a relative path provides good detail. Each Terragrunt unit runs tofu/terraform from its own .terragrunt-cache/.../ working directory, so a relative TF_LOG_PATH resolves independently per unit - you get one debug.log per unit, and Terragrunt's normal, [foo]/[bar]- tagged console output stays completely untouched:
TF_LOG=DEBUG TF_LOG_PATH=debug.log terragrunt run --all -- applyThen:
find . -name debug.logSince local/demo/clusterconn isn't a real, published provider, Terraform needs to be told where to find it some other way. A Terraform filesystem mirror is checked by terraform init / tofu init itself, before it ever tries the network - unlike dev_overrides, which init ignores entirely. terragrunt run --all always runs init automatically, so this is the mechanism that actually works with it. This section will present the steps to build and install the provider as a filesystem mirror and then configure Terraform to use it.
Terraform expects <mirror>/<host>/<namespace>/<type>/<version>/<os>_<arch>/<executable>.
cd providers/terraform-provider-clusterconn
VERSION=0.1.0
TARGET="$HOME/.terraform.d/plugin-mirror/local/demo/clusterconn/$VERSION/$(go env GOOS)_$(go env GOARCH)"
mkdir -p "$TARGET"
go build -o "$TARGET/terraform-provider-clusterconn_v$VERSION" .The CLI config is scoped to just this provider so every other provider (hashicorp/local,
hashicorp/random, etc.) still installs normally from the real Terraform registry
provider_installation {
filesystem_mirror {
path = "/<replace with your home dir>/.terraform.d/plugin-mirror"
include = ["local/demo/clusterconn"]
}
direct {
exclude = ["local/demo/clusterconn"]
}
}This project locates the CLI config file in the repo root provider-mirror.tfrc. You can see that the committed file has a hard-coded path that works on my filesystem. If you want to reproduce the project you'll have to edit that to be correct for your filesystem.
These directories comprise the project:
.
├── infrastructure
│ ├── delaware
│ │ ├── bar
│ │ └── foo
│ └── maryland
│ ├── bar
│ └── foo
├── modules
│ ├── bar-module
│ └── foo-module
└── providers
└── terraform-provider-clusterconn
└── internal
└── provider
Annotations on the structure above:
- The
infrastructuredirectory contains two stacksmarylandanddelawareeach of which deploys two modulesfoo-moduleandbar-module. - The
modulesdirectory has thefoo-moduleand thebar-module. - The
providerdirectory has the tiny hand-built provider served from the file system rather than a public registry.