This article presents a CI/CD process for Infrastructure‑as‑Code using Terragrunt with Terraform/OpenTofu. The goal is to enable safe, efficient, and consistent infrastructure changes through a structured workflow. The process is motivated by a few key needs:
- allowing multiple developers to work in parallel
- enforcing segregation‑of‑duty through pull requests and approvals
- enabling lean deployments by targeting only changed units instead of full stacks
- maintaining infrastructure consistency between the codebase and the actual infrastructure
Such a workflow only works when both the tooling and the team follow the CI/CD principles reliably.
Repository Architecture and Assumptions
A reliable CI/CD process depends on a well‑structured Terragrunt repository. The stack tree should be designed in a way that keeps deployments manageable and avoids running overly large or complex scopes within the pipeline. The approach described here builds on the Terragrunt repository architecture outlined in the following reference: A Scalable Terragrunt Repository Architecture for Enterprise Environments
To ensure consistency and stability, all Terraform/OpenTofu units should use remote state backends. This prevents local state conflicts and enables proper state locking and collaboration across teams.
Terragrunt must also have full visibility into all files that influence the configuration. This includes configuration files, scripts, and local Terraform modules. Features like include, make_read, or fileset + make_read enable that all required files are explicitly referenced and correctly tracked by Terragrunt.
locals {
yaml_vars = yamldecode(file(mark_as_read("${get_repo_root()}/global-vars/var-file.yaml")))
# make terragrunt aware of local tf module
local_tf_source = "${get_repo_root()}/local-tf-modules/unit-module"
local_tf_source_files = [
for f in fileset(local.local_tf_source, "*") :
file(mark_as_read(abspath("${local.local_tf_source}/${f}")))
]
}
terraform {
source = local.local_tf_source
}
CI/CD Requirements
To support a consistent and reliable Terragrunt‑based workflow, the CI/CD platform must provide a specific set of capabilities.
A suitable CI/CD platform is required - such as GitHub, Azure DevOps, or any tool offering comparable functionality that supports
- pull or merge requests
- pipeline as build validation within the PR/MR
- manual approvals both for the code review and for the Terraform/OpenTofu plan review
- role‑based access control (RBAC) to distinguish clearly between developers and reviewers, ensuring that only authorized people can approve or merge changes
- the main branch must be protected through branch policies that enforce pull or merge requests, require a minimum number of reviewers, and block unvalidated merges
- ideally, the platform should also support an auto‑complete function that merges a pull or merge request automatically once all conditions - successful pipeline run and required approvals - are met
Even with solid tooling in place, the process only works if the people involved understand and follow it. Developers and reviewers must be familiar with the CI/CD workflow, their responsibilities, and the required approval steps. These roles will be described in more detail in the next chapter.
Finally, the pipeline should run on ephemeral runners. These ensure clean, reproducible execution environments and help avoid caching conflicts that often occur with long‑running agents. They also support efficient provider caching, either through OpenTofu (from version 1.6 onward) or through Terragrunt’s own caching mechanisms. Running the workflow on continuously running agents is intentionally out of scope for this article.
Roles and Responsibilities
Two roles are essential for this CI/CD process: developers and reviewers. Each role contributes to the reliability and safety of the infrastructure workflow.
Developers work on feature branches and are responsible for implementing changes to the Infrastructure‑as‑Code definitions. They should not run plan or apply locally, unless they have access to a fully isolated environment dedicated to their work. All planning and applying should happen through the CI/CD pipeline to ensure consistency, traceability, and adherence to the approval process.
Reviewers are responsible for verifying both the code changes and the generated Terraform/OpenTofu plans before any modification is applied to the infrastructure and merged into the main branch. A reviewer may also be part of the developer group; however, it is essential that every pull or merge request is reviewed by a different person to maintain the required separation of duties.
Branching Strategy
The workflow is based on a simple but strict branching model using the main branch as the source of truth and feature branches for all changes. This structure enables clean separation of work, controlled deployments, and predictable integration of infrastructure updates.
Here is an illustration of the strategy:
FEATURE BRANCH (developer work)
|── develop ─── plan ─── apply ────────────── merge ──── delete branch
| |--- DRIFT WINDOW ---|
| |
| |
─────────────── stable ────────────────── merge received ───────────────>
MAIN BRANCH (IaC truth)
Feature Branches
Each change begins with the creation of a feature branch. Developers work exclusively within this branch and implement all modifications there. The CI/CD pipeline derives the affected Terragrunt units - and optionally any modified non‑HCL or non‑Terraform files - by comparing the feature branch against the main branch.
All deployments, including the apply step, are executed from the feature branch to ensure that the resulting infrastructure always reflects exactly the code that is pending for merge.
This also means that after a successful apply, and until the pull or merge request is completed, an infra‑drift exists between the live infrastructure and the main branch. To minimize this drift, the merge into main should happen immediately after a successful apply.
Main Branch
The main branch represents the current and authoritative state of the infrastructure, making it the central point of truth for Infrastructure‑as‑Code. As described above, drift between the main branch and the infrastructure should only occur in the short window between an apply on the feature branch and the subsequent merge.
To protect this source of truth, the main branch must be guarded by branch policies that enforce merging only through approved pull or merge requests with successful pipeline runs. Merging into Main Feature branches are merged into main exclusively through pull or merge requests. This ensures that all changes undergo mandatory code review and plan approval, maintaining the required 4‑eye principle.
Once the merge is completed:
- the feature branch is automatically deleted, and
- the process is considered successfully completed.
Change Detection and Scoping
A Terragrunt repository often contains multiple stacks and units, but the CI/CD pipeline should only consider the parts of the infrastructure that are actually affected by a change. For every pipeline run, the first step is therefore to determine which Terragrunt units have been modified. This is done by comparing the feature branch to the main branch, ensuring that only relevant units are included in the subsequent steps.
This targeted derivation enables a scoped and efficient build and deployment process. By limiting work to the units that changed, the pipeline becomes faster, lighter, and more predictable. Of course, some changes may have broader impact and can influence many or even all units within the repository. In such cases, the pipeline still captures every affected part, but the overall runtime naturally increases.
For larger infrastructures, it can be beneficial to separate the repository or pipeline execution by environment or region. This further reduces the scope of each pipeline run and helps keep deployments manageable and efficient.
Limitations and Design Constraints
The CI/CD process described in this article comes with some inherent limitations and design constraints that need to be understood before adopting it.
Destroy operations for Terragrunt units or entire stacks are not supported in this workflow. This limitation results from the use of a filters‑file on the feature branch. Although Terragrunt’s native Git‑based filtering can technically detect destroys, this process does not use those capabilities during planning or applying.
The requirement for a 4‑eye approval principle naturally increases development time, as every change must be reviewed by a second person. While this introduces some overhead, it significantly improves robustness and reduces the risk of unintended changes reaching the infrastructure.
Finally, concurrent apply steps are not possible due to the stateful nature of Terraform and OpenTofu. State locking prevents simultaneous modifications to the same infrastructure. However, concurrent plan steps are fully supported, as plans can be created without acquiring a state lock, allowing multiple feature branches to be validated in parallel.
Pipeline Steps
The CI/CD workflow consists of five core stages executed in a defined order: prepare → linting → plan → plan approval → apply.
Linting and planning may run in parallel, but both depend on the results of the prepare step, as they require the derived filters file. The apply step runs only when linting and plan have succeeded and a reviewer has approved the Terraform/OpenTofu plan.
Here is an overview of the steps:
+------------------+
| Prepare |
|------------------|
| - Merge check |
| - git diff -> |
| filters-file |
| - list units |
+--------+---------+
|
+-----------------+-----------------+
| |
+------+---------+ +-----------+------------+
| Lint/Validate | | Plan |
|----------------| |------------------------|
| - hcl val | | - no state lock |
| - tf val | | - parallel |
| - pre-commit | | - save plans |
| - [...] | | - refresh filters-file |
+--------+-------+ | - plan digest |
| +-----------+------------+
| |
+---------------+-----------------+
|
+--------v---------+
| Approval |
| (Plan) |
+------------------+
|
+-----------v------------+
| Apply |
|------------------------|
| - use saved plans |
| - adjusted filters-file|
| - stale check |
| - parallel |
+------------------------+
Prepare
The prepare step initializes the pipeline and determines the exact scope of the deployment.
It first checks whether the feature branch contains the most recent commits from the main branch.
COMMITID=<some-commit-id-of-feature-branch>
if git merge-base --is-ancestor origin/main "${COMMITID}"; then
echo "✔ Commit already contains latest changes from main."
exit
else
echo "✘ Commit is missing changes from main."
exit 1
fi
It then derives a scoped filters-file, based on Git changes and optionally limited to a specific (sub‑)stack tree, for example a particular environment such as dev, qas, or run. Additional filters can be applied to split execution across separate pipelines.
declare -a FILTERS_ARR=()
COMMITID=<some-commit-id-of-feature-branch>
TG_ENFORCED_SUBSTACK=<optional-specific-filter-like-for-environment-separation>
echo "deriving git based filter"
FILTERS_ARR+=("[origin/main...${COMMITID}]${TG_ENFORCED_SUBSTACK:+|${TG_ENFORCED_SUBSTACK}}")
echo "Listing all identified Terragrunt units based on provided filters:"
results="$(terragrunt find "${FILTER_ARGS[@]}")"
# Clean and deduplicate while preserving order:
# - Remove empty lines
# - Deduplicate preserving first occurrence
printf '%s\n' "$results" |
awk 'NF' |
awk '!seen[$0]++' \
>filters-file.txt
echo "filtes file content:"
cat filters-file.txt
Linting, Formatting, and Validation
Linting ensures that all code follows formatting and validation rules before any plans or changes are created.
Whenever possible, linting is based on the filters file so only affected units are validated. This includes validation of Terragrunt hcl files and Terraform/OpenTofu tf files/modules.
echo "Running HCL validationn..."
terragrunt hcl validate \
--filters-file filters-file.txt \
--all
echo "Running Tofu validation..."
terragrunt run \
--filters-file filters-file.txt \
--all \
-- validate
Multiple checks can also be done via pre-commit and the appropriate configuration. This also enables developers to run the checks locally, while commiting their changes to the feature branch.
Let’s assume the following .pre-commit-config.yaml, where we use ultiple hooks, including OpenTofu and Terragrunt Format.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/tofuutils/pre-commit-opentofu
rev: v2.2.2
hooks:
- id: tofu_fmt
args:
- --args=-recursive
- id: tofu_docs
args:
- --hook-config=--add-to-existing-file=true
- --hook-config=--create-file-if-not-exist=true
- repo: https://github.com/gruntwork-io/pre-commit
rev: v0.1.30
hooks:
- id: terragrunt-hcl-fmt
Now we utilize pre-commmit with only files between the main and the feature branch:
COMMITID=<some-commit-id-of-feature-branch>
pre-commit run --from-ref "origin/main" --to-ref ${COMMITID}
Beside the shown validations and format checks, many more can be added like tflint, checkov or many more.
Linting can also be executed in a dedicated pipeline when the derivation of affected files does not strictly need to occur in the prepare stage, like we do for the filters-file in our case.
Plan
The plan stage evaluates the intended infrastructure changes.
Terragrunt parallelization should be enabled to reduce execution time. Add the environment variable.
TG_PARALLELISM=<number-of-max-parallel-runs>
All generated plans must be saved explicitly. Since Terragrunt will save one Plan File per Terragrunt Unit, we need to collect all of them. We do this in a separate directory TFPLANS. The directory structure of the Terragrunt Stacks will remain the same and only contain the plan files.
TG_OUT_DIR=TFPLANS
Terraform/OpenTofu plans are created using the filters-file generated during the prepare step.
State locking is disabled during planning so multiple pipelines can run concurrently. It is acceptable because plans do not modify infrastructure. We use the detailed-exitcode of OpenTofu/Terraform, to evaluate whether we have changes or not.
set +e # in case you use "set -e" in the beginnning
terragrunt run \
--filters-file filters-file.txt \
--summary-per-unit \
--all \
-- \
plan -detailed-exitcode -lock=false
exit_code=$?
set -e
case "${exit_code}" in
0)
echo "Tofu has no changes."
;;
2)
echo "Changes for Tofu detected."
exit 0
;;
*)
echo "ERROR: tofu exited with code ${exit_code}"
exit 1
;;
esac
Now we create a new filters-file for the apply stage, based on actual detected changes. Additionally a readable plan summary should be produced to help reviewers understand the impact of a change.
process_plan() {
file=$1
echo -e "\n"
echo "#[section]Processing plan: ${file}"
ABS_PATH=$(pwd)
filename_txt=${file}.txt
filename_txt_tmp=${file}_tmp.txt
# get terragrunt cache directory of tfplan
tg_unit_dir=$(dirname "${file#*/TFPLANS/}")
terragrunt run \
--working-dir "./${tg_unit_dir}" \
--all \
--summary-disable \
--log-level warn \
-- show "${file}" \
>"${filename_txt_tmp}"
# search for the separation line which separates the outside of tf changed resources to avoid errors in diff
local line=$(awk '/──────────/{print NR+1}' ${filename_txt_tmp})
# if resources have changed outside of tofu, remove those from the out file and print them out.
if [ -z $line ]; then
cp ${filename_txt_tmp} ${filename_txt}
else
awk 'NR=='$line',NR==999999{print}' ${filename_txt_tmp} >${filename_txt}
echo " [objects have changed outside of tofu -> excluding it from plan comparison]"
fi
# we have to get rid of the stdout part of terragrunt.
sed -i 's/^.*tofu:[[:space:]]*//' "${filename_txt}"
rm ${filename_txt_tmp}
output_changes=""
resource_changes=""
plan_line=""
while read -r line; do
if [[ "$line" == *"Changes to Outputs:"* ]]; then
output_changes="📤 we have output changes"
fi
if [[ "$line" == *"Plan:"* ]]; then
resource_changes="🏗️ we have changes in resources"
plan_line="$line"
fi
done <"${filename_txt}"
# Build summary
summary="summary: "
[[ -n "$output_changes" ]] && summary+="$output_changes"
[[ -n "$output_changes" && -n "$resource_changes" ]] &&summary+=" | "
[[ -n "$resource_changes" ]] && summary+="$resource_changes[$plan_line]"
[[ -n "$output_changes" || -n "$resource_changes" ]] && echo" $summary"
# check, whether we have any changes in the plan
if [[ -n "$output_changes" || -n "$resource_changes" ]]; then
# add the terragrunt unit to the new filters file for later apply
echo "${tg_unit_dir}" >>filters-file-for-apply.txt
# print adjusted output
sed 's/^/ /' "${filename_txt}"
fi
}
while read -r file; do
found_any=true
process_plan "$file"
done < <(find ./TFPLANS/ -name "tfplan.tfplan" -type f)
Apply
The apply step executes the approved infrastructure changes.
Apply must not run immediately after planning. Instead, it waits for manual plan approval. Depending on the CI/CD platform, this approval may be part of the pipeline or a separate step.
A delay between planning and applying may cause stale plans, for example when another pipeline modifies the state in the meantime. In such cases, a simple restart of the plan stage resolves the issue.
The apply step only runs when linting, planning, and approval are completed successfully
Terraform/OpenTofu applies are executed using the previously created plans and we reduce the scope to only those Terragrunt Units, which actually contain changes, based on the adjusted filters-file from planning step
TG_PARALLELISM=<number-of-max-parallel-runs>
TG_OUT_DIR=TFPLANS
terragrunt run \
--filters-file filters-file-for-apply.txt \
--summary-per-unit \
--non-interactive \
--all \
-- apply
Summary
This article introduced a complete CI/CD workflow for managing Infrastructure‑as‑Code with Terragrunt and Terraform/OpenTofu. The process focuses on enabling safe, efficient, and predictable infrastructure changes through clear separation of work, automated validation, mandatory reviews, and well‑defined pipeline stages.
A strong repository structure forms the foundation of the workflow, supported by reliable CI/CD tooling, strict branch policies, and clear roles for developers and reviewers. Change detection and scoping ensure that only relevant units are processed, keeping deployments fast and lean. The pipeline itself follows a controlled sequence - prepare, lint, plan, approval, and apply - providing transparency, minimizing drift, and enforcing the 4‑eye principle.
While the workflow introduces certain limitations, such as the inability to destroy stacks directly or run concurrent applies, these constraints are deliberate decisions to maintain safety, consistency, and auditability in shared infrastructure environments.
Closing Remarks
A well‑designed CI/CD pipeline is not just a collection of scripts - it is a collaboration model. The combination of Terragrunt, structured repository design, and an approval‑driven workflow helps teams deliver infrastructure changes confidently and consistently. When supported by good tooling and clear responsibilities, this process scales effectively across environments, regions, and teams.
As with any engineering practice, the workflow should evolve with the needs of the organization. The principles described here - structure, automation, clarity, and safety - provide a solid foundation for building and maintaining a robust Infrastructure‑as‑Code platform.