Skip to main content
· Terraform · 4 min read

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.hclsub.hclenvironment.hclresource-group.tfvarsterragrunt.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.