Table of Contents
- Overview
- Module 1 — Data Sources in Terraform
- Module 2 — Remote Data Sharing with State
- Module 3 — External Data Sources for Cross-configuration Sharing
- Architecture Diagrams
- Reference Tables
- HCL Code Snippets
- Best Practices
- Summary
Overview
When first adopting Terraform, you typically manage a single configuration that grows over time. At some point, it becomes necessary to split this configuration into distinct blocks for:
- Separation of concerns
- Maintainability and ease of debugging
- Blast radius reduction (limiting the impact of a change)
- Faster development cycles
This guide focuses on communication between these configurations — how to share information from one configuration to another in a secure, decoupled way.
Module 1 — Data Sources in Terraform
What is a Data Source?
A data source in Terraform is an object defined by a provider that allows querying a platform or service to extract information. Unlike a resource, a data source is read-only — nothing is created, only consulted.
Three ways to inject external information into Terraform:
| Method | Description | Typical Usage |
|---|---|---|
| Input variables | Values passed at runtime | Simple parameterization |
| File functions | file(), templatefile() | Static content or templates |
| Data sources | Platform queries | Dynamic data |
Data source use cases:
- Retrieve available Availability Zones in an AWS region
- Query an AMI to get the most recent ID
- Read a TLS certificate from a certificate authority
- Get the current public IP address for firewall rules
- Retrieve AWS account info (account ID, etc.)
Dynamic Data Sources
Availability Zones Data Source
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "public-subnet-${count.index + 1}"
}
}
Dynamic AMI Data Source
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
}
Generative Data Sources
Some data sources don’t query an external platform but generate an internal document.
aws_iam_policy_document
data "aws_iam_policy_document" "allow_s3" {
statement {
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_policy" "s3_access" {
name = "s3-access-policy"
policy = data.aws_iam_policy_document.allow_s3.json
}
cloudinit_config (startup script)
data "cloudinit_config" "web_startup" {
gzip = true
base64_encode = true
part {
content_type = "text/x-shellscript"
content = templatefile("${path.module}/templates/startup.sh.tpl", {
environment = var.environment
company_name = var.company_name
})
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
user_data = data.cloudinit_config.web_startup.rendered
}
Module 2 — Remote Data Sharing with State
Why Split Configurations?
When infrastructure grows, a single terralith (monolithic configuration) becomes unmanageable. Split by:
- Teams / roles (networking team, app team, DB team)
- Blast radius — limit the impact of an erroneous
terraform apply - Deployment cycles — deploy the network and app independently
graph TD
subgraph Terralith["Terralith (monolithic)"]
M[main.tf\nVPC + EC2 + DB + IAM + LB]
end
subgraph Split["Separate Configurations"]
NET[network_config\nVPC, Subnets]
APP[app_config\nEC2, Security Group]
DB[db_config\nRDS, Parameters]
IAM[iam_config\nRoles, Policies]
end
NET -->|outputs: vpc_id, subnet_ids| APP
NET -->|outputs: vpc_id| DB
IAM -->|outputs: role_arns| APP
DB -->|outputs: db_endpoint| APP
terraform_remote_state
The terraform_remote_state data source allows a configuration to query the state of another configuration, accessing only its root module outputs.
# In app_config/main.tf
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = var.network_bucket_config.bucket
key = var.network_bucket_config.key
region = var.network_bucket_config.region
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = data.terraform_remote_state.networking.outputs.public_subnet_ids[0]
vpc_security_group_ids = [aws_security_group.web.id]
}
resource "aws_security_group" "web" {
name = "app-web-sg"
vpc_id = data.terraform_remote_state.networking.outputs.vpc_id
}
Required outputs in the source configuration:
# In network_config/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
S3 backend configuration:
# In network_config/terraform.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "network.tfstate"
region = "us-east-1"
}
}
Key Considerations
| Aspect | Detail |
|---|---|
| Credentials | Each configuration can have distinct credentials |
| Connectivity | The calling config must be able to reach the source S3 bucket |
| Network restrictions | PrivateLink, IP allowlists → verify accessibility |
| Sensitive data | Entire state is accessible, not just outputs |
Module 3 — External Data Sources for Cross-configuration Sharing
Alternatives to terraform_remote_state
HashiCorp officially discourages using terraform_remote_state. Two major problems:
1. Security issue
- The calling configuration needs full read access to the source state
terraform_remote_stateignores thesensitivemarker on outputs
2. Tight coupling
- The calling configuration is directly tied to the internal structure of the source configuration
tfe_outputs Data Source
Secure alternative if the source configuration uses HCP Terraform or Terraform Enterprise as backend.
data "tfe_outputs" "networking" {
organization = "mycompany"
workspace = "network-config"
}
resource "aws_instance" "web" {
subnet_id = data.tfe_outputs.networking.values.public_subnet_ids[0]
}
# Non-sensitive outputs
resource "aws_db_instance" "main" {
db_subnet_group_name = data.tfe_outputs.networking.nonsensitive_values.db_subnet_group
}
| Attribute | Content | Sensitivity |
|---|---|---|
values | All outputs | Marked sensitive automatically |
nonsensitive_values | Non-sensitive outputs only | Usable without unwrapping |
Third-Party Storage Services
graph LR
subgraph Source["Source Configuration"]
NET[network_config]
end
subgraph Store["Storage Service"]
SSM[AWS SSM\nParameter Store]
CONSUL[HashiCorp Consul]
VAULT[HashiCorp Vault\nSecrets]
end
subgraph Consumer["Consumer Configuration"]
APP[app_config]
end
NET -->|writes vpc_id, subnet_ids| SSM
NET -->|writes config data| CONSUL
SSM -->|data source aws_ssm_parameter| APP
CONSUL -->|data source consul_keys| APP
| Category | AWS | Azure | GCP | Agnostic |
|---|---|---|---|---|
| General K-V | SSM Parameter Store | App Config | Cloud Datastore | Redis, Consul |
| Secrets | Secrets Manager | Key Vault | Secret Manager | HashiCorp Vault |
| K8s | EKS ConfigMaps | AKS ConfigMaps | GKE ConfigMaps | Kubernetes ConfigMaps |
Special Data Sources
| Data Source | Provider | Usage |
|---|---|---|
local_file | hashicorp/local | Read local files |
local_sensitive_file | hashicorp/local | Files containing secrets |
http | hashicorp/http | Generic HTTP requests to an API |
external | hashicorp/external | Execute an arbitrary script |
data "external" "network_info" {
program = ["python3", "${path.module}/scripts/get_network_info.py"]
query = {
environment = var.environment
region = var.aws_region
}
}
output "retrieved_vpc_id" {
value = data.external.network_info.result["vpc_id"]
}
Migrating Network Data to SSM
Source configuration (network_config) — writing to SSM
resource "aws_ssm_parameter" "vpc_id" {
name = "/${var.environment}/networking/vpc-id"
description = "VPC ID for the application"
type = "String"
insecure_value = aws_vpc.main.id
tags = {
Environment = var.environment
}
}
resource "aws_ssm_parameter" "public_subnet_ids" {
name = "/${var.environment}/networking/public-subnet-ids"
description = "Public subnet IDs"
type = "StringList"
value = join(",", aws_subnet.public[*].id)
}
Consumer configuration (app_config) — reading from SSM
data "aws_ssm_parameter" "vpc_id" {
name = "/${var.environment}/networking/vpc-id"
}
data "aws_ssm_parameter" "public_subnet_ids" {
name = "/${var.environment}/networking/public-subnet-ids"
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = split(",", data.aws_ssm_parameter.public_subnet_ids.value)[0]
vpc_security_group_ids = [aws_security_group.web.id]
}
resource "aws_security_group" "web" {
name = "app-web-sg"
vpc_id = data.aws_ssm_parameter.vpc_id.value
}
Cross-configuration Dependencies
Scenario 1 — Blocked deletion (best case)
AWS refuses to delete a subnet with attached network interfaces
Scenario 2 — Disconnected resources (dangerous)
Deleting a resource used by another configuration → service disruption
Scenario 3 — Silent renaming (insidious)
Renaming an S3 bucket in one config → breaks logging/caching in another config
Architecture Diagrams
Multi-environment Structure
graph TD
subgraph Environments
DEV[dev/]
STAGING[staging/]
PROD[prod/]
end
subgraph SharedModules["Shared Modules"]
NET_MOD[module: networking]
APP_MOD[module: app]
DB_MOD[module: database]
end
DEV -->|dev.tfvars| NET_MOD
DEV -->|dev.tfvars| APP_MOD
STAGING -->|staging.tfvars| NET_MOD
PROD -->|prod.tfvars| NET_MOD
PROD -->|prod.tfvars| DB_MOD
subgraph State["Remote State Backends"]
S3_DEV[(S3: dev.tfstate)]
S3_STG[(S3: staging.tfstate)]
S3_PRD[(S3: prod.tfstate)]
end
DEV --- S3_DEV
STAGING --- S3_STG
PROD --- S3_PRD
Data Sharing Alternatives
flowchart LR
subgraph S1["Source Configuration"]
C1[network_config]
end
subgraph Methods["Sharing Methods"]
direction TB
RM[terraform_remote_state\nFull state access]
TFE[tfe_outputs\nHCP Terraform/TFE only]
SSM[AWS SSM\nDecoupled]
VAULT[HashiCorp Vault\nSecure + Decoupled]
HTTP[HTTP API\nLast resort]
end
subgraph S2["Target Configuration"]
C2[app_config]
end
C1 --> RM
C1 --> TFE
C1 --> SSM
C1 --> VAULT
RM --> C2
TFE --> C2
SSM --> C2
VAULT --> C2
Reference Tables
Data Sharing Strategies Between Configurations
| Strategy | Coupling | Security | Prerequisites | Recommendation |
|---|---|---|---|---|
terraform_remote_state | Tight | Full state access | Compatible backend | Avoid (discouraged by HashiCorp) |
tfe_outputs | Tight | Outputs only | HCP Terraform / TFE | Good if already on HCP/TFE |
| AWS SSM Parameter Store | Loose | Granular IAM | AWS | Recommended on AWS |
| Azure App Config / Key Vault | Loose | Granular RBAC | Azure | Recommended on Azure |
| HashiCorp Consul | Loose | Granular ACLs | Consul cluster | Cloud-agnostic |
| HashiCorp Vault | Loose | Secured secrets | Vault cluster | For sensitive data |
external data source | Variable | Variable | Local script | Last resort |
Terralith vs. Separate Configurations
| Aspect | Terralith | Separate Configurations |
|---|---|---|
| Complexity | Growing → unmanageable | Divided by domain |
| Blast radius | High (all or nothing) | Limited to the affected config |
| Dependencies | Managed automatically | Manual / via data sources |
| Execution time | Long (full graph) | Short per config |
| Collaboration | Frequent conflicts | Parallel work possible |
HCL Code Snippets
tfvars by Environment
# dev.tfvars
environment = "dev"
instance_type = "t3.micro"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
vpc_cidr = "10.0.0.0/16"
# staging.tfvars
environment = "staging"
instance_type = "t3.small"
public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24"]
vpc_cidr = "10.1.0.0/16"
# prod.tfvars
environment = "prod"
instance_type = "t3.medium"
public_subnet_cidrs = ["10.2.1.0/24", "10.2.2.0/24"]
vpc_cidr = "10.2.0.0/16"
Recommended Project Structure
project/
├── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
├── environments/
│ ├── dev/
│ │ ├── network/
│ │ │ ├── main.tf
│ │ │ ├── variables.tf
│ │ │ ├── outputs.tf
│ │ │ └── terraform.tfvars
│ │ └── app/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
└── README.md
Best Practices
- Never hardcode resource IDs between configurations — use data sources
- Document every cross-configuration dependency in the README
- Define stable outputs in source configurations — avoid breaking renames
- Use the
sensitivemarker on outputs containing secrets - Enable state locking (DynamoDB for S3 backend) to avoid conflicts
- Prefer SSM/Consul over
terraform_remote_stateto reduce coupling - Test changes on non-critical environments before production
- Establish change controls for shared resources
Summary
Module 1 — Data Sources
Data sources allow dynamically querying cloud platforms for information like AMI IDs, Availability Zones, or policy documents. Generative data sources (cloudinit_config, aws_iam_policy_document) generate correctly formatted documents without external queries.
Module 2 — Remote State
The terraform_remote_state data source enables sharing outputs between configurations via their state stored in a common backend (S3). Warning: full read access to state, not just outputs.
Module 3 — External Data Sources
To overcome the security and coupling limitations of terraform_remote_state, HashiCorp recommends decoupled alternatives: tfe_outputs (if using HCP Terraform/TFE), or third-party storage services like AWS SSM Parameter Store, HashiCorp Vault, or Consul.
When splitting a terralith into separate configurations, Terraform loses the global view of dependencies. It is the team’s responsibility to document, monitor, and manage these cross-configuration dependencies to prevent unexpected service disruptions.
Search Terms
terraform · managing · configurations · infrastructure · ci/cd · devops · data · sources · source · sharing · ssm · alternatives · configuration · cross-configuration · dynamic · external · remote · state · terraformremotestate