Skip to content

Production Checklist

Shipping Terraform and Terragrunt to production is not one big decision but a checklist of smaller ones — each covered earlier in this course — that all have to hold true at the same time.

None of these are new ideas at this point. What is new is seeing them side by side as one list, because production readiness is really just all of them being true simultaneously, not any single one of them done especially well.

  • Remote S3 backend with locking configured from day one. use_lockfile = true on the S3 backend, set up before the first apply, not retrofitted after a corrupted state file forces the issue. Covered in State Management.
  • Provider and module versions pinned, not floating. The committed .terraform.lock.hcl pins exact provider versions and checksums; module source references use a specific tag or version constraint, never a branch name that can move underneath you. Covered in Foundations and Modules.
  • Every plan reviewed in CI before every apply, with reviewers specifically watching for unexpected -/+ resource replacements rather than skimming the plan like a code diff. Covered in this module’s CI/CD lesson.
  • Sensitive values kept out of variables and, where possible, out of state, pulled from a secrets manager via a data source instead of typed into a tfvars file, with the state backend itself encrypted at rest. Covered in State Management and this module’s secrets lesson.
  • tflint, a security scanner, and ideally policy-as-code wired into CI, so known-bad patterns and organizational rules are enforced mechanically instead of depending on a human catching every case by eye. Covered in this module’s policy-as-code lesson.
  • Terragrunt dependency blocks driving rollout order, not a person remembering that the VPC has to apply before the database. Covered in Terragrunt Fundamentals.
  • terraform test / .tftest.hcl coverage for any module doing something non-trivial, so a refactor of a module’s internals gets caught by an assertion instead of by a surprised downstream consumer. Covered in Modules.
# vpc/terragrunt.hcl and rds/terragrunt.hcl both use dependency blocks
# so rollout order is driven by Terragrunt, not a person's memory.
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "mock-vpc-id"
private_subnet_ids = ["mock-subnet-id"]
}
mock_outputs_allowed_terraform_commands = ["plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_id = dependency.vpc.outputs.private_subnet_ids[0]
}
modules/rds/tests/main.tftest.hcl
run "sets_expected_engine" {
command = plan
variables {
identifier = "app-db-test"
}
assert {
condition = aws_db_instance.this.engine == "postgres"
error_message = "RDS module did not default to the postgres engine"
}
}

Put all of it together and this course adds up to a single, concrete shape — not a list of disconnected facts, but one repository that actually looks like this:

infra/
├── root.hcl
├── vpc/
│ └── terragrunt.hcl
├── ec2/
│ └── terragrunt.hcl
└── rds/
└── terragrunt.hcl

root.hcl at the top holds the shared backend configuration and provider generation, included by every unit below it:

infra/root.hcl
remote_state {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
infra/rds/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../modules/rds"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "mock-vpc-id"
private_subnet_ids = ["mock-subnet-id"]
}
mock_outputs_allowed_terraform_commands = ["plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_id = dependency.vpc.outputs.private_subnet_ids[0]
}

Every piece here traces back to an earlier lesson: use_lockfile = true is the State Management module’s locking story; root.hcl plus include is Terragrunt Fundamentals’ DRY backend pattern; the dependency block is what lets rds reference vpc’s real output without a person remembering which unit has to apply first; and the ../../modules/rds source, pinned in a real repo to a tag rather than left floating, is Modules’ versioning discipline.

The whole tree ships through exactly the CI/CD shape from earlier in this module: terragrunt run --all plan runs on every pull request touching infra/, posts its output for review, and terragrunt run --all apply runs only after merge to main, using the same OIDC-federated role rather than a stored key. Nothing here is a new mechanism — it is the same pieces from every earlier module, wired together into one system that deploys vpc, then ec2 and rds behind it, in the order the dependency blocks describe, gated by a plan a human actually read.

flowchart TB
  root["root.hcl: shared S3 backend, use_lockfile = true"]
  root --> vpc["vpc unit"]
  root --> ec2["ec2 unit"]
  root --> rds["rds unit"]
  vpc -->|dependency block: vpc_id, subnet_ids| ec2
  vpc -->|dependency block: vpc_id, subnet_ids| rds
  pr["Pull request"] -->|terragrunt run --all plan| review["Reviewed for -/+ replacements"]
  review -->|merge to main| apply["terragrunt run --all apply"]
  apply --> vpc
  apply --> ec2
  apply --> rds
root.hcl plus three dependent units, deployed through a CI pipeline that gates plan and apply
Why does pinning module versions to a specific tag matter, rather than pointing source at a floating branch
Why should a remote backend with locking be configured from day one rather than added later
Why do Terragrunt dependency blocks matter for a multi-unit rollout instead of relying on an engineer applying units in the right order manually
Why does reviewing a plan in CI specifically for -/+ replacements matter as part of this checklist