Terragrunt (live)
Language Overview¶
Terragrunt is a thin wrapper for Terraform that provides extra tools for keeping your Terraform configurations DRY (Don't Repeat Yourself), working with multiple Terraform modules, and managing remote state. This guide covers Terragrunt best practices for multi-environment infrastructure.
Key Characteristics¶
- Purpose: DRY Terraform configurations, remote state management, multi-environment orchestration
- File Extension:
.hcl(HCL syntax) - Primary Use: Managing Terraform across multiple environments, regions, and accounts
- Version: Terragrunt 0.45.x+ (compatible with Terraform 1.5.x+)
Quick Reference¶
| Category | Convention | Example | Notes |
|---|---|---|---|
| Files | |||
| Root Config | terragrunt.hcl |
terragrunt.hcl |
Root configuration file |
| Environment Config | {env}/terragrunt.hcl |
prod/terragrunt.hcl |
Per-environment config |
| Module Config | {module}/terragrunt.hcl |
vpc/terragrunt.hcl |
Per-module config |
| Structure | |||
| Directory Layout | Environment-based | {env}/{region}/{module} |
Hierarchical structure |
| Root HCL | Shared config | DRY backend, provider config | Reusable configuration |
| Key Blocks | |||
terraform |
Terraform settings | source = "../modules/vpc" |
Module source |
include |
Include parent config | include { path = find_in_parent_folders() } |
Inherit settings |
inputs |
Module variables | inputs = { vpc_cidr = "10.0.0.0/16" } |
Pass variables |
remote_state |
State configuration | Backend settings | S3, GCS, etc. |
dependency |
Module dependencies | dependency "vpc" { } |
Inter-module deps |
| Functions | |||
find_in_parent_folders() |
Find parent config | Auto-locate root HCL | Traverse up directories |
get_terragrunt_dir() |
Current directory | Working directory path | Current module path |
path_relative_to_include() |
Relative path | Generate unique names | Path-based naming |
| Best Practices | |||
| DRY Principle | Use root HCL | Shared backend, provider | Avoid repetition |
| Dependencies | Explicit deps | Use dependency blocks |
Clear relationships |
| State Isolation | Per-module state | Separate state files | Blast radius reduction |
| Run All | Use with caution | terragrunt run-all |
Test in non-prod first |
Directory Structure¶
Standard Layout¶
Use the live/<account>/<region>/<environment>/<stack> pattern:
infrastructure/
├── modules/ # Reusable Terraform modules
│ ├── vpc/
│ ├── eks/
│ └── rds/
├── live/ # Live infrastructure
│ ├── terragrunt.hcl # Root configuration
│ ├── prod/
│ │ ├── us-east-1/
│ │ │ ├── vpc/
│ │ │ │ └── terragrunt.hcl
│ │ │ ├── eks/
│ │ │ │ └── terragrunt.hcl
│ │ │ └── rds/
│ │ │ └── terragrunt.hcl
│ │ └── us-west-2/
│ │ └── vpc/
│ │ └── terragrunt.hcl
│ ├── staging/
│ │ └── us-east-1/
│ │ ├── vpc/
│ │ │ └── terragrunt.hcl
│ │ └── eks/
│ │ └── terragrunt.hcl
│ └── dev/
│ └── us-east-1/
│ └── vpc/
│ └── terragrunt.hcl
└── README.md
Root terragrunt.hcl¶
Centralized Configuration¶
## @module root_terragrunt
## @description Root Terragrunt configuration for remote state and provider settings
## @version 1.0.0
## @author Tyler Dukes
## @last_updated 2025-10-28
## Generate backend configuration for all child modules
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.aws_region
encrypt = true
dynamodb_table = "terraform-locks"
s3_bucket_tags = {
Name = "Terraform State"
Environment = local.environment
}
dynamodb_table_tags = {
Name = "Terraform Locks"
Environment = local.environment
}
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
## Generate provider configuration
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
provider "aws" {
region = "${local.aws_region}"
default_tags {
tags = {
Environment = "${local.environment}"
ManagedBy = "Terragrunt"
Region = "${local.aws_region}"
}
}
}
EOF
}
## Local variables available to all child configurations
locals {
# Parse environment and region from path
# Expected path: live/<environment>/<region>/<stack>/terragrunt.hcl
path_parts = split("/", path_relative_to_include())
environment = length(local.path_parts) > 0 ? local.path_parts[0] : "dev"
aws_region = length(local.path_parts) > 1 ? local.path_parts[1] : "us-east-1"
# Account ID mapping
account_ids = {
prod = "111111111111"
staging = "222222222222"
dev = "333333333333"
}
account_id = lookup(local.account_ids, local.environment, "333333333333")
# Common tags
common_tags = {
Environment = local.environment
ManagedBy = "Terragrunt"
Region = local.aws_region
AccountId = local.account_id
}
}
## Terraform version constraints
terraform {
extra_arguments "common_vars" {
commands = get_terraform_commands_that_need_vars()
env_vars = {
TF_INPUT = "false"
}
}
}
Child terragrunt.hcl Files¶
Basic Child Configuration¶
## @module vpc_live
## @description VPC configuration for production us-east-1
## @version 1.0.0
## @author Tyler Dukes
## @last_updated 2025-10-28
## Include root configuration
include "root" {
path = find_in_parent_folders()
}
## Reference Terraform module
terraform {
source = "${get_repo_root()}/modules//vpc"
# Or use Git repository
# source = "git::ssh://git@github.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"
}
## Module inputs
inputs = {
vpc_name = "prod-vpc-us-east-1"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = false
tags = {
Project = "MyApp"
Owner = "Platform Team"
}
}
Configuration with Dependencies¶
## @module eks_live
## @description EKS cluster depending on VPC
## @version 1.0.0
## @author Tyler Dukes
## @last_updated 2025-10-28
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "${get_repo_root()}/modules//eks"
}
## Dependency on VPC module
dependency "vpc" {
config_path = "../vpc"
# Mock outputs for faster plan/validate without applying VPC first
mock_outputs = {
vpc_id = "vpc-mock-id"
private_subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
cluster_name = "prod-eks-us-east-1"
cluster_version = "1.28"
# Use VPC outputs as inputs
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
node_groups = {
general = {
desired_capacity = 3
max_capacity = 10
min_capacity = 1
instance_types = ["t3.large"]
}
}
}
Configuration with Multiple Dependencies¶
## @module rds_live
## @description RDS database depending on VPC and security groups
## @version 1.0.0
## @author Tyler Dukes
## @last_updated 2025-10-28
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "${get_repo_root()}/modules//rds"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-id"
private_subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
dependency "security_groups" {
config_path = "../security-groups"
mock_outputs = {
database_security_group_id = "sg-mock-id"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
identifier = "prod-postgres"
engine = "postgres"
engine_version = "15.3"
instance_class = "db.t3.large"
allocated_storage = 100
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
security_group_ids = [dependency.security_groups.outputs.database_security_group_id]
backup_retention_period = 7
multi_az = true
}
Generate Blocks¶
Generating Files¶
## Generate versions.tf
generate "versions" {
path = "versions.tf"
if_exists = "overwrite"
contents = <<-EOF
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
}
## Generate data sources
generate "common_data" {
path = "data.tf"
if_exists = "overwrite"
contents = <<-EOF
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
EOF
}
Hooks¶
Before and After Hooks¶
terraform {
source = "${get_repo_root()}/modules//vpc"
# Format code before plan/apply
before_hook "terraform_fmt" {
commands = ["plan", "apply"]
execute = ["terraform", "fmt"]
}
# Validate before plan
before_hook "terraform_validate" {
commands = ["plan"]
execute = ["terraform", "validate"]
}
# Run custom script after apply
after_hook "notify_deployment" {
commands = ["apply"]
execute = ["bash", "${get_repo_root()}/scripts/notify-deployment.sh"]
run_on_error = false
}
}
Running Terragrunt¶
Common Commands¶
## Initialize and apply single module
cd live/prod/us-east-1/vpc
terragrunt init
terragrunt plan
terragrunt apply
## Run plan for all modules in current directory and subdirectories
terragrunt run-all plan
## Apply all modules in dependency order
terragrunt run-all apply
## Destroy specific module
terragrunt destroy
## Destroy all modules in reverse dependency order
terragrunt run-all destroy
## Validate all configurations
terragrunt run-all validate
## Format all HCL files
terragrunt hclfmt
## Show outputs
terragrunt output
## Show dependency graph
terragrunt graph-dependencies
Dependency Management¶
Explicit Dependencies¶
## Define dependencies to ensure correct apply order
dependencies {
paths = [
"../vpc",
"../security-groups"
]
}
Skip Dependencies¶
## Skip dependency for faster iteration during development
dependency "vpc" {
config_path = "../vpc"
skip_outputs = true
mock_outputs = {
vpc_id = "vpc-mock-id"
}
}
Best Practices¶
Use Mock Outputs¶
## Always provide mock outputs for faster plan/validate
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-00000000"
public_subnet_ids = ["subnet-00000001", "subnet-00000002"]
private_subnet_ids = ["subnet-00000003", "subnet-00000004"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
mock_outputs_merge_strategy_with_state = "shallow"
}
Use get_repo_root()¶
## Good - Portable across different directory depths
terraform {
source = "${get_repo_root()}/modules//vpc"
}
## Avoid - Brittle with multiple ../
terraform {
source = "../../../modules//vpc"
}
Keep Inputs DRY with Locals¶
locals {
# Define common inputs in locals
common_tags = {
Environment = "production"
ManagedBy = "Terragrunt"
Project = "MyApp"
}
vpc_config = {
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
inputs = merge(
local.vpc_config,
{
vpc_name = "prod-vpc"
tags = local.common_tags
}
)
Testing¶
Validate Terragrunt Configuration¶
## Validate configuration syntax
terragrunt validate-all
## Check formatting
terragrunt hclfmt --terragrunt-check
## Format files
terragrunt hclfmt
## Validate specific module
cd envs/production
terragrunt validate
Testing with terragrunt plan¶
## Plan all modules
terragrunt run-all plan
## Plan specific module
cd envs/production/vpc
terragrunt plan
## Save plan for testing
terragrunt plan -out=tfplan
terraform show -json tfplan > tfplan.json
Policy Testing with conftest¶
Test Terragrunt-generated plans:
## Test plan with policies
terragrunt plan -out=tfplan
terraform show -json tfplan | conftest test -p policy/ -
## Test all modules
terragrunt run-all plan -out=tfplan
for dir in envs/*/*; do
cd "$dir"
terraform show -json tfplan | conftest test -p ../../../policy/ -
cd -
done
Example policy:
## policy/terragrunt.rego
package terragrunt
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_s3_bucket"
not resource.values.versioning[_].enabled
msg := sprintf("S3 bucket %s must have versioning enabled", [resource.address])
}
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_instance"
not startswith(resource.values.instance_type, "t3")
msg := sprintf("Instance %s must use t3 instance type", [resource.address])
}
Testing Dependencies¶
Verify module dependencies resolve correctly:
## Test dependency graph
terragrunt graph-dependencies | dot -Tpng > dependencies.png
## Validate dependencies exist
terragrunt run-all validate
## Test dependency order
terragrunt run-all plan --terragrunt-log-level debug
Integration Testing¶
Test full infrastructure deployment:
## tests/integration-test.sh
#!/bin/bash
set -e
echo "Testing Terragrunt configuration..."
## Validate all configurations
terragrunt run-all validate
## Plan all changes
terragrunt run-all plan
## Apply in test environment
cd envs/test
terragrunt run-all apply -auto-approve
## Run smoke tests
./tests/smoke-test.sh
## Destroy test resources
terragrunt run-all destroy -auto-approve
echo "Integration tests passed!"
Testing with Terratest¶
## tests/terragrunt_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestTerragruntVPC(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../envs/test/vpc",
TerraformBinary: "terragrunt",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
}
CI/CD Testing¶
## .github/workflows/terragrunt-test.yml
name: Terragrunt Tests
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Setup Terragrunt
run: |
wget https://github.com/gruntwork-io/terragrunt/releases/latest/download/terragrunt_linux_amd64
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
- name: Validate format
run: terragrunt hclfmt --terragrunt-check
- name: Validate all
run: terragrunt run-all validate
- name: Plan all
run: terragrunt run-all plan
Testing State Management¶
## Verify remote state configuration
terragrunt run-all init -backend=false
## Test state isolation
cd envs/production
terragrunt state list
cd ../staging
terragrunt state list
## Verify no shared state
Mock Testing¶
Test without actually deploying:
## Use -lock=false for testing
terragrunt plan -lock=false
## Test with mock data
export TF_VAR_environment=test
export TF_VAR_region=us-east-1
terragrunt plan
Performance Testing¶
## Measure plan time
time terragrunt run-all plan
## Test with parallelism
terragrunt run-all plan --terragrunt-parallelism 10
## Measure per-module performance
for dir in envs/production/*; do
cd "$dir"
echo "Testing $dir"
time terragrunt plan
cd -
done
Common Pitfalls¶
Dependency Path Typos¶
Issue: Incorrect dependency paths cause Terragrunt to fail silently or create resources in wrong order.
Example:
## Bad - Typo in dependency path
dependency "network" {
config_path = "../networking" # ❌ Typo! Should be ../network
}
inputs = {
vpc_id = dependency.network.outputs.vpc_id # Fails at runtime
}
Solution: Verify dependency paths and test with terragrunt run-all plan.
## Good - Correct dependency path
dependency "network" {
config_path = "../network" # ✅ Correct path
}
inputs = {
vpc_id = dependency.network.outputs.vpc_id
}
## Good - Use relative paths from terragrunt.hcl location
dependency "database" {
config_path = "${get_terragrunt_dir()}/../rds" # ✅ Explicit relative path
}
Key Points:
- Dependency paths are relative to
terragrunt.hcllocation - Use
terragrunt graph-dependenciesto visualize dependencies - Test with
terragrunt run-all planbefore apply - Check for circular dependencies with dependency graph
Missing Mock Outputs in Dependencies¶
Issue: Dependencies without mock_outputs cause failures during initial plan before dependencies exist.
Example:
## Bad - No mock outputs
dependency "vpc" {
config_path = "../vpc"
# ❌ No mock_outputs! Fails on first plan
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id # Error: vpc module not yet applied
}
Solution: Always provide mock outputs for dependencies.
## Good - Mock outputs for planning
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-12345" # ✅ Used during initial plan
subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.subnet_ids
}
Key Points:
- Mock outputs allow planning before dependencies exist
- Mock values should match expected output types
- Use
mock_outputs_allowed_terraform_commandsto control when mocks apply - Real outputs override mocks when dependencies are applied
Include Block Order Matters¶
Issue: Include blocks are processed in order; later includes can't reference earlier ones.
Example:
## Bad - Trying to reference included locals
include "root" {
path = find_in_parent_folders()
}
include "region" {
path = find_in_parent_folders("region.hcl")
}
## ❌ Can't reference locals from included files here
inputs = {
tags = merge(local.common_tags, local.region_tags) # Error: locals not defined
}
Solution: Define locals after includes or use input variables.
## Good - Locals defined after includes
include "root" {
path = find_in_parent_folders()
}
include "region" {
path = find_in_parent_folders("region.hcl")
}
locals {
common_tags = {
ManagedBy = "Terragrunt"
Environment = "production"
}
}
inputs = merge(
include.root.locals.tags,
include.region.locals.regional_tags,
local.common_tags
)
Key Points:
- Includes are processed top-to-bottom
- Reference included locals with
include.<name>.locals.<var> - Define module-specific locals after includes
- Use
merge()to combine configurations from multiple includes
Remote State Backend Configuration Duplication¶
Issue: Repeating remote state configuration in every terragrunt.hcl file causes maintenance burden.
Example:
## Bad - Repeated in every module
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state" # ❌ Duplicated everywhere
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Solution: Define remote state in root terragrunt.hcl and include it.
## Good - Root terragrunt.hcl
## _root/terragrunt.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "my-terraform-state-${get_aws_account_id()}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.aws_region
encrypt = true
dynamodb_table = "terraform-locks-${get_aws_account_id()}"
}
}
## Good - Child module references root
## modules/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders() # ✅ Inherits remote_state config
}
Key Points:
- Define remote state once in root
terragrunt.hcl - Use
includeto inherit configuration - Use
get_aws_account_id()for multi-account setups path_relative_to_include()ensures unique state keys
Path Functions Confusion¶
Issue: Mixing up get_terragrunt_dir(), get_parent_terragrunt_dir(), and path_relative_to_include().
Example:
## Bad - Wrong path function
locals {
environment = basename(get_terragrunt_dir()) # ❌ Returns module name, not environment
}
## Bad - Incorrect relative path
dependency "vpc" {
config_path = get_parent_terragrunt_dir() # ❌ Points to parent dir, not sibling module
}
Solution: Use correct path functions for each use case.
## Good - Correct path functions
locals {
# Get environment from parent directory structure
# /envs/production/vpc/terragrunt.hcl -> "production"
environment = basename(dirname(get_terragrunt_dir()))
# Get module name
# /envs/production/vpc/terragrunt.hcl -> "vpc"
module_name = basename(get_terragrunt_dir())
# Get path relative to root
# /envs/production/vpc/terragrunt.hcl -> "envs/production/vpc"
relative_path = path_relative_to_include()
}
## Good - Sibling module dependency
dependency "vpc" {
config_path = "../vpc" # ✅ Relative to current module
}
## Good - Find root terragrunt.hcl
include "root" {
path = find_in_parent_folders() # ✅ Searches up directory tree
}
Key Points:
get_terragrunt_dir(): Absolute path to current module directoryget_parent_terragrunt_dir(): Absolute path to parent with terragrunt.hclpath_relative_to_include(): Relative path from root to current modulefind_in_parent_folders(): Searches up tree for file
Anti-Patterns¶
❌ Avoid: Hardcoded Values Everywhere¶
## Bad - Hardcoded values in each child
inputs = {
environment = "prod"
region = "us-east-1"
}
## Good - Parse from path in root terragrunt.hcl
locals {
path_parts = split("/", path_relative_to_include())
environment = local.path_parts[0]
region = local.path_parts[1]
}
❌ Avoid: Not Using Dependencies¶
## Bad - Manually passing outputs
inputs = {
vpc_id = "vpc-1234567890abcdef" # Hardcoded!
}
## Good - Use dependency block
dependency "vpc" {
config_path = "../vpc"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
}
❌ Avoid: Inconsistent Directory Structure¶
## Bad - Inconsistent paths
live/
├── prod-vpc/
├── staging/vpc/
└── dev_us_east_1/vpc/
## Good - Consistent structure
live/
├── prod/us-east-1/vpc/
├── staging/us-east-1/vpc/
└── dev/us-east-1/vpc/
❌ Avoid: Not Using generate Blocks¶
## Bad - Provider configuration duplicated in every module
## Repeated in every terragrunt.hcl
## Good - Generate provider in root
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/TerraformRole"
}
}
EOF
}
❌ Avoid: Not Using remote_state¶
## Bad - Each module configures backend separately
## Good - Configure remote state in root
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
❌ Avoid: Not Using run_cmd for Dynamic Values¶
## Bad - Static values that should be dynamic
locals {
account_id = "123456789012" # ❌ Hardcoded
}
## Good - Get dynamically
locals {
account_id = run_cmd("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
}
❌ Avoid: Deep Module Paths Without include¶
## Bad - Duplicating terraform source in each child
terraform {
source = "git::https://github.com/org/modules.git//vpc?ref=v1.0.0"
}
## Good - Define in root, reference in children
## Root terragrunt.hcl
terraform {
source = "${get_parent_terragrunt_dir()}/modules//vpc"
}
## Child just includes
include "root" {
path = find_in_parent_folders()
}
Security Best Practices¶
Secure State Backend Configuration¶
Always use encrypted remote state backends with proper access controls.
## Bad - Local state (not secure for teams)
## terragrunt.hcl
terraform {
source = "git::https://github.com/org/modules//vpc"
}
## State stored locally - no encryption, no locking!
## Good - S3 backend with encryption
remote_state {
backend = "s3"
config = {
bucket = "mycompany-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true # Server-side encryption
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
dynamodb_table = "terraform-locks" # State locking
## Restrict access
acl = "private"
## Enable versioning for recovery
versioning = true
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
Secrets Management¶
Never commit secrets to terragrunt.hcl files.
## Bad - Hardcoded secrets
## terragrunt.hcl
inputs = {
database_password = "SuperSecret123" # NEVER!
api_key = "sk_live_abc123" # Exposed in version control!
}
## Good - Use environment variables
inputs = {
database_password = get_env("TF_VAR_database_password", "")
api_key = get_env("TF_VAR_api_key", "")
}
## Better - Use AWS Secrets Manager/Parameter Store
locals {
secrets = yamldecode(sops_decrypt_file("${get_terragrunt_dir()}/secrets.enc.yaml"))
}
inputs = {
database_password = local.secrets.database_password
api_key = local.secrets.api_key
}
## Best - Use SOPS for encrypted files
## Encrypt secrets file
## sops --encrypt secrets.yaml > secrets.enc.yaml
Input Validation¶
Validate inputs to prevent misconfigurations.
## Good - Validate environment names
locals {
environment = get_env("ENVIRONMENT", "dev")
## Validate environment
valid_environments = ["dev", "staging", "prod"]
is_valid_env = contains(local.valid_environments, local.environment)
}
inputs = {
environment = local.is_valid_env ? local.environment : run_cmd(
"--terragrunt-quiet", "echo",
"Error: Invalid environment ${local.environment}", "&&", "exit", "1"
)
## Validate CIDR blocks
vpc_cidr = get_env("VPC_CIDR")
## Validation in module will check format
}
## Good - Validate AWS region
locals {
aws_region = get_env("AWS_REGION", "us-east-1")
valid_regions = [
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
"eu-west-1", "eu-central-1", "ap-southeast-1"
]
}
inputs = {
aws_region = contains(local.valid_regions, local.aws_region) ? local.aws_region : "us-east-1"
}
Dependency Security¶
Trust but verify module sources and dependencies.
## Bad - Unverified module source
terraform {
source = "github.com/random-user/terraform-modules//vpc" # Untrusted!
}
## Bad - Using latest/master (no version pinning)
terraform {
source = "git::https://github.com/org/modules.git//vpc?ref=master" # Unpredictable!
}
## Good - Pin to specific verified version
terraform {
source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.2.3"
}
## Good - Use release tags with verification
terraform {
source = "git::ssh://git@github.com/your-org/modules.git//vpc?ref=v1.2.3"
}
dependency "vpc" {
config_path = "../vpc"
## Skip outputs if VPC doesn't exist (safe default)
skip_outputs = true
## Mock outputs for plan operations
mock_outputs = {
vpc_id = "vpc-mock-id"
subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
IAM Role Security¶
Use assume_role with proper constraints.
## Bad - Over-privileged role
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/TerraformAdmin" # Too broad!
}
}
EOF
}
## Good - Environment-specific roles with external ID
locals {
account_id = get_env("AWS_ACCOUNT_ID")
environment = get_env("ENVIRONMENT")
external_id = get_env("TERRAFORM_EXTERNAL_ID") # Additional security
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/Terraform-${local.environment}"
external_id = "${local.external_id}"
session_name = "terragrunt-${local.environment}-$${USER}"
}
default_tags {
tags = {
ManagedBy = "Terragrunt"
Environment = "${local.environment}"
Owner = "$${USER}"
}
}
}
EOF
}
Sensitive Output Protection¶
Mark sensitive outputs appropriately.
## Bad - Exposing sensitive data
dependency "rds" {
config_path = "../rds"
}
inputs = {
db_host = dependency.rds.outputs.endpoint
db_password = dependency.rds.outputs.password # Logged in plan output!
}
## Good - Use sensitive flag in module outputs
## In RDS module outputs.tf:
output "password" {
value = random_password.db_password.result
sensitive = true # Prevents logging in Terraform output
}
## In terragrunt.hcl - outputs remain sensitive
dependency "rds" {
config_path = "../rds"
}
inputs = {
db_host = dependency.rds.outputs.endpoint
db_password = dependency.rds.outputs.password # Still sensitive
}
Lock File Integrity¶
Use and verify lock files.
## Bad - Ignoring lock files
## .gitignore
.terraform.lock.hcl # DON'T IGNORE!
## Good - Commit lock files
## .gitignore should NOT include:
## .terraform.lock.hcl (commit this!)
## In CI/CD pipeline
## terraform.yml
steps:
- name: Verify lock file
run: |
if ! git diff --exit-code .terraform.lock.hcl; then
echo "Error: Lock file has uncommitted changes"
exit 1
fi
- name: Run terragrunt
run: |
terragrunt run-all plan
Prevent Accidental Destruction¶
Use prevent_destroy and require confirmations.
## Good - Require confirmation for prod
locals {
environment = get_env("ENVIRONMENT")
}
## Prevent accidental destroy in production
terraform {
before_hook "prevent_destroy" {
commands = ["destroy"]
execute = local.environment == "prod" ? [
"bash", "-c",
"echo 'ERROR: Cannot destroy production environment!' && exit 1"
] : ["echo", "Destroy allowed in ${local.environment}"]
}
}
## Require manual approval
terraform {
before_hook "require_approval" {
commands = ["apply"]
execute = local.environment == "prod" ? [
"bash", "-c",
"read -p 'Apply to PRODUCTION? (yes/no): ' confirm && " +
"[ \"$confirm\" = \"yes\" ] || exit 1"
] : ["echo", "Proceeding with apply"]
}
}
Secure Hooks¶
Validate hook scripts and limit execution.
## Bad - Arbitrary command execution
terraform {
after_hook "notify" {
commands = ["apply"]
execute = ["sh", "-c", get_env("NOTIFY_COMMAND")] # Dangerous!
}
}
## Good - Controlled hook execution
terraform {
after_hook "notify_success" {
commands = ["apply"]
execute = ["bash", "${get_terragrunt_dir()}/scripts/notify.sh", "success", "${path_relative_to_include()}"]
run_on_error = false
}
after_hook "notify_failure" {
commands = ["apply"]
execute = ["bash", "${get_terragrunt_dir()}/scripts/notify.sh", "failure", "${path_relative_to_include()}"]
run_on_error = true
}
}
## Ensure hook scripts have proper permissions
## chmod 755 scripts/notify.sh
## Never chmod 777!
Audit and Compliance Logging¶
Enable comprehensive logging for audit trails.
## Good - Log all Terragrunt operations
terraform {
extra_arguments "common_vars" {
commands = get_terraform_commands_that_need_vars()
env_vars = {
TF_LOG = "INFO"
TF_LOG_PATH = "${get_terragrunt_dir()}/terraform.log"
}
}
before_hook "log_start" {
commands = ["apply", "destroy"]
execute = [
"bash", "-c",
"echo \"[$(date -u +%Y-%m-%dT%H:%M:%SZ)] User: $USER, " +
"Action: ${command}, Path: ${path_relative_to_include()}\" >> " +
"/var/log/terragrunt-audit.log"
]
}
}
Tool Configurations¶
.terragrunt-cache¶
Add to .gitignore:
## Terragrunt cache
.terragrunt-cache/
**/.terragrunt-cache/
## Terraform files
*.tfstate
*.tfstate.backup
.terraform/
VSCode Extensions¶
- Terraform: Syntax highlighting for HCL
- HashiCorp Terraform: Official HashiCorp extension
References¶
Official Documentation¶
Gruntwork Resources¶
Best Practice Guides¶
Status: Active