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?

Alt