Simplify Terraform Stacks with a Shared Dependencies Module
Managing external dependencies in Terraform-based infrastructure-as-code projects can quickly become cumbersome — especially in multi-region environments or where multiple teams interact with shared services like container registries, log analytics, application insights, or key vaults. In such cases, it’s crucial to strike a balance between reuse and encapsulation, while minimizing unnecessary API chatter with Azure.
This article presents a technique to centralize and streamline the consumption of shared infrastructure by isolating data source lookups into a dedicated Terraform component. This shared “dependencies” component not only keeps deployment blocks clean and declarative, but also enables consistent access to enriched resource data across all other components in the stack.
The Problem: Redundant and Chatty Infrastructure Lookups
When consuming existing shared infrastructure — such as Azure Monitor, Key Vaults, or Container Registries — developers often find themselves repeating the same data blocks across multiple components. This redundancy makes it harder to maintain consistency, leads to bloated codebases, and introduces extra calls to the Azure API surface that can slow down deployments or hit rate limits.
Furthermore, many teams need more than just the resource name or ID — they need enriched metadata like connection strings, instrumentation keys, login servers, etc. Managing these details in every module increases complexity and risk of drift.
The Solution: A Shared Dependencies Component
To address this, we define a dedicated Terraform component that fetches and enriches all shared infrastructure references. This component becomes the single source of truth for shared dependencies and exposes structured outputs that other stack components can consume without duplicating logic.
Input Definition in the Deployment Layer
We begin by defining a deployment block (deployments.tfdeploy) that passes minimal, necessary information—just the resource names and their respective resource groups.
identity_token "azurerm" {
audience = ["api://AzureADTokenExchange"]
}
locals {
client_id = "00000000-0000-0000-0000-000000000000"
tenant_id = "00000000-0000-0000-0000-000000000000"
primary_location = "westus3"
application_name = "foobar-app1"
}
deployment "dev" {
inputs = {
identity_token = identity_token.azurerm.jwt
client_id = local.client_id
tenant_id = local.tenant_id
primary_location = local.primary_location
application_name = local.application_name
subscription_id = "00000000-0000-0000-0000-000000000000"
environment_name = "dev"
log_analytics = {
name = "log-foobar-app1-devops-dev"
resource_group = "rg-foobar-product1-devops-dev-shared"
}
application_insights = {
name = "appi-foobar-app1-devops-dev"
resource_group = "rg-foobar-product1-devops-dev-shared"
}
container_registry = {
name = "cr1234"
resource_group = "rg-foobar-product1-devops-dev-shared"
}
keyvault = {
name = "kv-1234"
resource_group = "rg-foobar-product1-devops-dev-shared"
}
tags = {
application = local.application_name
environment = "dev"
}
}
}
This lightweight deployment configuration cleanly separates core application parameters from infrastructure references, deferring resolution of those references to the dependencies component.
Wiring the Dependencies Component
Within the stack, we define the dependencies component and pass along the relevant inputs from the deployment block:
component "dependencies" {
source = "./src/terraform/dependencies"
inputs = {
primary_location = var.primary_location
application_name = var.application_name
environment_name = var.environment_name
tags = var.tags
log_analytics = var.log_analytics
application_insights = var.application_insights
container_registry = var.container_registry
keyvault = var.keyvault
}
providers = {
azurerm = provider.azurerm.this
}
}
This component encapsulates all data source lookups for shared infrastructure resources. The dependencies module simply goes off and gets references to the existing resources we need in this stack and hydrates output objects that can be used consistently across each component of our stack.
Resolving Resources Using Data Sources
Therefore, the anatomy of this dependencies module is extremely simple. we have the data sources that are populated by the input variables that match the deployment’s input variables.
Inside the dependencies module, each shared resource is referenced via a Terraform data block using the provided input values:
data "azurerm_log_analytics_workspace" "shared" {
name = var.log_analytics.name
resource_group_name = var.log_analytics.resource_group
}
data "azurerm_application_insights" "shared" {
name = var.application_insights.name
resource_group_name = var.application_insights.resource_group
}
data "azurerm_container_registry" "shared" {
name = var.container_registry.name
resource_group_name = var.container_registry.resource_group
}
data "azurerm_key_vault" "shared" {
name = var.keyvault.name
resource_group_name = var.keyvault.resource_group
}
And then we have outputs that we will use the structure of which to define the inputs for each of our downstream components. These data blocks are then exposed via structured output objects:
output "log_analytics" {
value = {
resource_group = data.azurerm_log_analytics_workspace.shared.resource_group_name
id = data.azurerm_log_analytics_workspace.shared.id
name = data.azurerm_log_analytics_workspace.shared.name
workspace_id = data.azurerm_log_analytics_workspace.shared.workspace_id
}
}
output "application_insights" {
value = {
resource_group = data.azurerm_application_insights.shared.resource_group_name
id = data.azurerm_application_insights.shared.id
name = data.azurerm_application_insights.shared.name
instrumentation_key = data.azurerm_application_insights.shared.instrumentation_key
connection_string = data.azurerm_application_insights.shared.connection_string
location = data.azurerm_application_insights.shared.location
}
sensitive = true
}
output "container_registry" {
value = {
resource_group = data.azurerm_container_registry.shared.resource_group_name
id = data.azurerm_container_registry.shared.id
name = data.azurerm_container_registry.shared.name
endpoint = data.azurerm_container_registry.shared.login_server
}
}
output "keyvault" {
value = {
resource_group = data.azurerm_key_vault.shared.resource_group_name
id = data.azurerm_key_vault.shared.id
name = data.azurerm_key_vault.shared.name
}
}
Defining Inputs in Downstream Components
With this pattern, each downstream component only needs to define matching input variable structures to consume these outputs. For example:
variable "log_analytics" {
type = object({
resource_group = string
id = string
name = string
workspace_id = string
})
}
variable "application_insights" {
type = object({
resource_group = string
id = string
name = string
instrumentation_key = string
connection_string = string
location = string
})
}
variable "container_registry" {
type = object({
resource_group = string
id = string
name = string
endpoint = string
})
}
variable "keyvault" {
type = object({
resource_group = string
id = string
name = string
})
}
And the wiring to downstream components becomes simple and declarative:
component "global" {
source = "./src/terraform/global"
inputs = {
primary_location = var.primary_location
application_name = var.application_name
environment_name = var.environment_name
tags = var.tags
log_analytics = component.dependencies.log_analytics
application_insights = component.dependencies.application_insights
container_registry = component.dependencies.container_registry
keyvault = component.dependencies.keyvault
}
providers = {
azurerm = provider.azurerm.this
random = provider.random.this
}
}
Conclusion
This technique of using a shared dependencies component offers a clean and scalable approach to managing references to existing shared infrastructure in Terraform. It keeps your deployment layer declarative, avoids repeating data lookups across components, and provides a centralized place for transforming and enriching shared resource data.
Whether you’re building for multiple environments, regions, or teams, consolidating infrastructure access through this pattern ensures consistency and simplifies downstream consumption, all while reducing deployment-time chattiness with Azure APIs.
By abstracting shared dependencies into a dedicated module, you enable your Terraform stacks to scale with clarity and maintainability.
What do you think about this approach? It’s probably one that I would never have used if I didn’t have Terraform Stacks because the plumbing of setting up an entire root module just to initialize Data Sources would be absolutely ludicrous from a cost-benefit perspective — but with Stacks — it just works. So what do you think? What could go wrong? What am I missing?
