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_CASEso they line up with theTF_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 fallback | Notes |
|---|---|---|
TAILSCALE_AUTH_KEY | TF_VAR_TAILSCALE_AUTH_KEY | one-to-one |
ZITADEL_MASTERKEY | TF_VAR_ZITADEL_MASTERKEY | TF_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 applytry(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 invariables.hcl. - Outputs of other stacks: pull them via a
dependencyblock, 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 +
datablocks in Terraform. - HashiCorp Vault + provider.
- A SOPS-encrypted
secrets.enc.hclcommitted to the repo, decrypted on the fly.
For small teams, local secrets.hcl + TF_VAR_* env in CI is a simple
working MVP.