Secrets in Terragrunt

How to store and consume sensitive values (passwords, tokens, API keys) in this Terragrunt blueprint.

The idea

secrets.hcl is a regular Terragrunt config containing a single locals { ... } block with flat key/value pairs — the secrets for one AWS account. The file lives at the account directory level:

aws/
  <account_id>/
    backend_config.hcl
    variables.hcl
    secrets.hcl   <-- here

Modules that need a secret read this file via read_terragrunt_config and pick the field they need from local.secrets.<NAME>.

File shape

  • One locals { ... } block.
  • Keys in UPPER_SNAKE_CASE so they line up with the TF_VAR_* env fallback (see below).
  • One key, one scalar value (string). No nested maps/objects — keeps the env fallback trivial and consumers simple.
  • A short comment next to each secret pointing at the consumer.

Example aws/123456789012/secrets.hcl:

locals {
  # used by aws/<id>/eu-central-1/ec2/<name>-tailscale
  TAILSCALE_AUTH_KEY = "tskey-auth-..."
 
  # used by aws/<id>/eu-central-1/secrets/<name>-sm-zitadel
  ZITADEL_MASTERKEY                        = "..."
  ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD = "..."
  ZITADEL_DATABASE_POSTGRES_USER_USERNAME  = "zitadel_user"
  ZITADEL_DATABASE_POSTGRES_USER_PASSWORD  = "..."
}

.gitignore — mandatory

secrets.hcl must never land in git. Add to the repo root .gitignore (or blueprints/01-terragrunt/.gitignore):

**/secrets.hcl

If you need a documented template in the repo, keep a secrets.example.hcl next to it with placeholder values and commit that instead.

Reading from a module

The canonical pattern is try(local.secrets.X, get_env("TF_VAR_X", "<default>")). The module works both when secrets.hcl is present locally and when it is absent and the value is injected through env (CI/CD, local shell).

Pattern A — inside the module’s variables.hcl

Use this when variables.hcl builds locals that root.hcl then auto-merges into inputs. For example, secrets/<name>-sm-alloy/variables.hcl:

locals {
  variables = read_terragrunt_config(find_in_parent_folders("variables.hcl"))
 
  secrets = try(
    read_terragrunt_config("${get_repo_root()}/aws/${local.variables.locals.account_id}/secrets.hcl").locals,
    {}
  )
 
  alloy_faro_api_key = try(
    local.secrets.ALLOY_FARO_API_KEY,
    get_env("TF_VAR_ALLOY_FARO_API_KEY", "")
  )
 
  secret_string = jsonencode({
    alloy_faro_api_key = local.alloy_faro_api_key
  })
}

try(..., {}) on the file read is what lets the module degrade gracefully to the env fallback when secrets.hcl does not exist on disk — otherwise the missing-file error would short-circuit the whole evaluation.

Pattern B — inside the module’s terragrunt.hcl

Use this when the value is passed straight into the module’s inputs (e.g. for terraform-aws-modules/secrets-manager):

include "root" {
  path           = find_in_parent_folders("root.hcl")
  merge_strategy = "deep"
}
 
terraform {
  source = "tfr:///terraform-aws-modules/secrets-manager/aws?version=2.0.1"
}
 
locals {
  parent_vars = read_terragrunt_config(find_in_parent_folders("variables.hcl"))
 
  secrets = try(
    read_terragrunt_config("${get_repo_root()}/aws/${local.parent_vars.locals.account_id}/secrets.hcl").locals,
    {}
  )
 
  tailscale_auth_key = try(
    local.secrets.TAILSCALE_AUTH_KEY,
    get_env("TF_VAR_TAILSCALE_AUTH_KEY", "")
  )
}
 
inputs = {
  tailscale_auth_key = local.tailscale_auth_key
}

Naming convention

Local key (secrets.hcl)Env fallbackNotes
TAILSCALE_AUTH_KEYTF_VAR_TAILSCALE_AUTH_KEYone-to-one
ZITADEL_MASTERKEYTF_VAR_ZITADEL_MASTERKEYTF_VAR_ prefix + same key

TF_VAR_ is Terraform’s standard env override prefix. Keeping the same name on both sides means switching “file locally → env in CI” needs no mapping layer.

Injecting secrets without the file

For CI/CD, or a quick local run without secrets.hcl:

export TF_VAR_TAILSCALE_AUTH_KEY="tskey-auth-..."
export TF_VAR_ZITADEL_MASTERKEY="..."
terragrunt apply

try(local.secrets.X, get_env("TF_VAR_X", "")) will pick up the env value when the file is missing.

Why account-scoped

secrets.hcl sits at the account directory level (aws/<account_id>/), not per-module and not per-region. Reasons:

  • Secrets are naturally account-bound: API tokens, DB passwords, IAM keys — all scoped to one AWS account.
  • Modules pull it via ${get_repo_root()}/aws/${account_id}/secrets.hcl, so the path is predictable and independent of how deep the module sits.
  • One file per account → no duplicated values across regions.

What does NOT belong in secrets.hcl

  • Non-secrets: regions, account_id, env, project, tags — those live in variables.hcl.
  • Outputs of other stacks: pull them via a dependency block, not by hand.
  • Dynamic values (e.g. machine PATs minted by the Zitadel provider): also via dependency, not pasted into secrets.

Next step: get out of the file

The file approach is an interim step. Target state for production:

  • AWS Secrets Manager / SSM Parameter Store + data blocks in Terraform.
  • HashiCorp Vault + provider.
  • A SOPS-encrypted secrets.enc.hcl committed to the repo, decrypted on the fly.

For small teams, local secrets.hcl + TF_VAR_* env in CI is a simple working MVP.