Production-Ready Terragrunt: tfEnv, SOPS, TFLint, and Pre-Commit Hooks
Terragrunt solves the Terraform DRY problem: no repeating backend configs, no copy-pasting provider blocks across modules. But setting it up properly requires threading several tools together. This is the stack I’ve converged on after years of iterating on infrastructure repositories.
Repository Structure
%%{ init: { 'look': 'handDrawn' } }%%
graph TD
A[modules/<br/>Reusable Terraform] --> B[subscription-1/<br/>env: prod<br/>region: we]
A --> C[subscription-1/<br/>env: staging<br/>region: we]
A --> D[subscription-2/<br/>env: prod<br/>region: us]
B --> E[global.hcl<br/>Provider config]
C --> E
D --> E
B --> F[sub.hcl<br/>Subscription-level vars]
C --> F
D --> G[sub.hcl<br/>Different subscription]
B --> H[environment.hcl<br/>env: prod]
C --> I[environment.hcl<br/>env: staging]
D --> H
B --> J[resource-group.tfvars<br/>per-resource vars]
C --> J
D --> J
H --> K[terragrunt.hcl<br/>Generates provider<br/>Configures remote_state<br/>Merges inputs]
The hierarchy flows: global.hcl → sub.hcl → environment.hcl → resource-group.tfvars → terragrunt.hcl. Each layer adds specificity.
Core terragrunt.hcl Pattern
# terragrunt.hcl in each subscription/environment/resource-group/
locals {
# Merge all variable files
global_vars = read_terragrunt_config(find_in_parent_folders("global.hcl"))
sub_vars = read_terragrunt_config(find_in_parent_folders("sub.hcl"))
env_vars = read_terragrunt_config(find_in_parent_folders("environment.hcl"))
tfvars = read_terragrunt_config(find_in_parent_folders("resource-group.tfvars"))
# Merge into inputs
merged = merge(
local.global_vars.locals,
local.sub_vars.locals,
local.env_vars.locals,
local.tfvars.locals,
)
# SOPS decryption for secrets
decrypted = decrypt_yaml(find_in_parent_folders("secrets.hcl.enc"))
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "azurerm" {
features {}
subscription_id = "${local.merged.azure_subscription_id}"
tenant_id = "${local.merged.azure_tenant_id}"
}
EOF
}
remote_state {
backend = "azurerm"
config = {
resource_group_name = local.merged.tfstate_resource_group
storage_account_name = local.merged.tfstate_storage_account
container_name = local.merged.tfstate_container
key = "${path_relative_to_include()}/terraform.tfstate"
}
}
inputs = merge(local.merged, local.decrypted)
The generate "provider" block means you never commit a provider block — Terragrunt generates it per-environment. remote_state is also centralized here.
SOPS for Secrets Management
# Install SOPS
brew install sops
# or: pip install sops
# Create encrypted secrets file
sops --encrypt secrets.hcl.enc > secrets.hcl
# Edit encrypted
sops secrets.hcl.enc
# .gitignore
*.hcl.enc
!secrets.hcl.enc
# secrets.hcl.enc (encrypted by SOPS)
locals {
db_password = "prod-password-123"
api_key_staging = "staging-key-456"
}
SOPS encrypts the YAML file with GPG or a cloud KMS key. The decrypted values are only available at runtime — not stored in git.
SOPS is the right tool here because it encrypts values and leaves the YAML structure readable, so a diff still shows which key changed. If you need whole files encrypted instead — TLS keys, .env files, anything binary — git-crypt covers that case with transparent filters and no per-value markup.
Pre-Commit Hooks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terragrunt_fmt
- id: terragrunt_validate
- id: tfsec
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: terragrunt-validate-all
name: Validate all Terragrunt modules
entry: terragrunt run-all validate
language: system
pass_filenames: false
pip install pre-commit
pre-commit install
Every git commit now runs: terraform fmt → terragrunt fmt → tflint → tfsec → terragrunt validate.
Local hooks alone are advisory — anyone can --no-verify past them. The same config has to run as a CI job too, which is what turns it from a suggestion into a gate.
TFLint Configuration
# .tflint.hcl
config {
module = true
force = false
}
plugin "azurerm" {
enabled = true
version = "0.29.0"
source = "github.com/terraform-linters/tflint-ruleset-azure"
}
rule "terraform_deprecated_interpolation" {
enabled = true
}
rule "terraform_documented_outputs" {
enabled = true
}
rule "terraform_documented_variables" {
enabled = true
}
rule "terraform_naming_convention" {
enabled = true
format = "snake_case"
}
rule "terraform_required_version" {
enabled = false
}
rule "terraform_required_providers" {
enabled = true
}
rule "terraform_unused_declarations" {
enabled = true
}
TFLint catches: undocumented outputs/variables, naming convention violations, unused variable declarations, deprecated syntax. These rules enforce consistency across a multi-team IaC repo.
Version Management with tfenv
# Install tfenv
brew install tfenv
# List available versions
tfenv list-remote | grep "1.9"
# Pin version
echo "1.9.0" > .terraform-version
tfenv reads .terraform-version and switches the active Terraform binary. Combined with GitOps, every module pins its Terraform version explicitly.
CI Validation Pipeline
%%{ init: { 'look': 'handDrawn' } }%%
graph TD
A[PR opened] --> B[pre-commit hooks<br/>fmt + lint]
B -->|fail| C[Block merge]
B -->|pass| D[terraform init<br/>terragrunt run-all init]
D --> E[terraform validate<br/>terragrunt run-all validate]
E -->|fail| C
E --> F[tflint<br/>tfsec]
F -->|fail| C
F -->|pass| G[terraform plan<br/>terragrunt run-all plan-out]
G --> H[Post plan to PR<br/>Show resource changes]
H --> I[Approval gate]
I -->|approve| J[terragrunt apply<br/>terragrunt run-all apply]
J --> K[State updated<br/>Remote backend]
The CI pipeline validates: formatting → init → validate → lint → plan. The plan is posted to the PR for human review before any apply runs.