Terraform Professional: CI/CD Pipelines and Deployment Strategies
A Terraform CI/CD pipeline is the automated path that takes a configuration change from a commit to applied infrastructure: format and validity checks, provider installation, a plan, review or policy gates, and the apply. At the professional level you must make that path non-interactive and safe (formatting gates, provider caching and offline installs, the setup-terraform wrapper, CLI tokens, VCS triggers, the API-driven workflow, run tasks and approval rules) and choose how changes reach users: blue/green, canary, feature toggles, immutable images, promotion of tested artifacts and rollback.
On this page10 sections
- What does a Terraform CI/CD pipeline do?
- How do you build a formatting gate with terraform fmt?
- How should CI runners install providers quickly and safely?
- How do CI runners run the Terraform CLI?
- How do VCS-driven and API-driven workspaces start runs?
- How do run tasks and approval rules gate an apply?
- How do blue/green deployments work with Terraform?
- How do canary releases shift traffic gradually?
- How do immutable images and artifact promotion keep releases tested?
- How do you roll back a bad Terraform deployment?
- Build read-only CI gates with terraform fmt and gate merges on pull-request speculative plans
- Install providers in CI quickly and safely with a plugin cache, the lock file, filesystem mirrors or -plugin-dir
- Drive HCP Terraform from pipelines: TF_TOKEN_ credentials, VCS trigger modes, API-driven configuration versions and the setup-terraform wrapper
- Place run tasks at the right stage and enforcement level, and decide where auto-apply is acceptable
- Implement blue/green and canary releases with weighted target groups and feature-toggle counts, in a safe order
- Promote tested artifacts with immutable images, exact versions and HCP Packer channels, and roll back by applying known-good configuration
What does a Terraform CI/CD pipeline do?
A Terraform CI/CD pipeline runs the same small set of stages for every change: check formatting and validity, install providers, produce a plan, put that plan in front of a reviewer or a policy check, and apply it. The pipeline either runs the Terraform CLI itself on a runner (GitHub Actions, GitLab, Jenkins and similar) or hands the work to HCP Terraform through one of its three workflows.
| HCP Terraform workflow | What starts a run | Typical pipeline use |
|---|---|---|
| VCS-driven | Pushes, pull requests or tags in the connected repository | No pipeline code at all: merges deploy, pull requests get speculative plans |
| CLI-driven | terraform plan/apply on a runner with a cloud block | An existing CI system that wants remote execution and state |
| API-driven | An uploaded configuration version and, optionally, a Runs API call | Custom tooling (Jenkins, internal platforms) that packages the configuration itself |
Whatever the workflow, a professional pipeline keeps three properties: every run is non-interactive, the plan a person approves is the plan that is applied, and critical environments never apply without review. Saved plans, -detailed-exitcode, TF_IN_AUTOMATION and locking are covered in the lesson on planning and applying changes; dynamic provider credentials and variable files are covered in the sibling topic on that subject.
How do you build a formatting gate with terraform fmt?
Run terraform fmt -check -recursive from the repository root. It never rewrites files and exits non-zero when any file is not in canonical format, which is what fails the job.
| Flag | Effect |
|---|---|
-check | Read-only: implies -write=false; exit status 0 only when every input is already formatted, non-zero otherwise |
-recursive | Also processes subdirectories. Without it, fmt handles only the given (or current) directory, so modules/ and envs/ are silently skipped |
-diff | Prints the formatting changes each file needs, so the log shows what is wrong, not just where |
-list | Lists files whose formatting differs (on by default); -list=false hides even the file names |
The general rule: only -check makes fmt read-only and turns unformatted files into a failing exit code; the other flags change what is printed, not whether files are rewritten. Scope is a separate decision, so a gate that omits -recursive passes while nested modules drift. terraform validate is a different gate: it checks syntax and internal consistency, not layout, so badly formatted but valid code passes it. A useful read-only gate with readable logs is terraform fmt -check -diff -recursive.
How should CI runners install providers quickly and safely?
Use a provider plugin cache only where one init runs at a time and commit the dependency lock file; on isolated runners use a filesystem mirror or -plugin-dir instead.
The plugin cache
- Set with
TF_PLUGIN_CACHE_DIRorplugin_cache_dirin the CLI configuration file; the two are equivalent. - The cache is not guaranteed to be concurrency safe. Jobs that run
terraform initat the same time must each have their own cache directory, or they can read half-written packages. - Terraform uses a cached package only when it matches a checksum already recorded in
.terraform.lock.hcl. With no lock entry, init installs from upstream so that it can record checksums, and the cache is bypassed. Commit the lock file to get cache hits. TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILElets init use the cache without a lock entry, but the new entry then holds only the cached package's checksum, which may be invalid on other platforms. It is an escape hatch, not a fix.- Even with a cache, init still asks its installation methods (normally the registry) which versions exist. A cache saves downloads; it does not make a runner work offline.
Runners with no registry access
| Approach | Contacts a registry? |
|---|---|
terraform init -plugin-dir=PATH | No: installs only from that directory, as if it were a filesystem mirror |
CLI config provider_installation with only a filesystem_mirror method | No: there is no direct method to fall back to |
TF_PLUGIN_CACHE_DIR pointed at the directory | Yes, for version metadata |
terraform init -get=false | Yes: it only skips child module installation |
The mirror directory uses the hostname/namespace/type/ layout. -lockfile=readonly (verify the lock file, never update it) is a good CI setting but conflicts with -upgrade, and neither flag changes where providers come from.
How do CI runners run the Terraform CLI?
CI runners run the Terraform CLI non-interactively: GitHub Actions usually installs it with hashicorp/setup-terraform, whose default wrapper changes what each step outputs, and CLI-driven runs against HCP Terraform authenticate with a TF_TOKEN_ environment variable.
The setup-terraform wrapper
By default hashicorp/setup-terraform installs a wrapper script around the terraform binary (input terraform_wrapper, default true). The wrapper captures each call's streams and exposes them as step outputs, and it adds its own lines to the step's output.
| Wrapper output | Contains |
|---|---|
steps.<id>.outputs.stdout | The call's STDOUT, where plan text and fmt -check file lists go |
steps.<id>.outputs.stderr | The error stream only |
steps.<id>.outputs.exitcode | The numeric exit code of the call |
There is no output named after the command (no outputs.plan). Posting a plan as a pull-request comment reads outputs.stdout from the plan step, ideally run with -no-color.
The wrapper gets in the way when a step redirects or pipes Terraform's output, for example terraform output -json > outputs.json followed by jq: the extra lines break the JSON. Set terraform_wrapper: false for such jobs. Only that input removes the wrapper; other step settings change the output's content, not the extra wrapper lines.
With continue-on-error: true on a step, GitHub records two results: outcome (before continue-on-error, so failure when the command failed) and conclusion (after it, so success). The job has not failed, so failure() is false. To react to a failed but tolerated check, test steps.<id>.outcome == 'failure' or steps.<id>.outputs.exitcode != '0'.
Authenticating to HCP Terraform with TF_TOKEN_
Export a TF_TOKEN_<hostname> environment variable holding an API token; the CLI reads it for that host without any credentials file or interactive terraform login.
- Encode the host name: each period becomes a single underscore, and a hyphen may be written as a double underscore (hyphens are usually not allowed in variable names).
app.terraform.io→TF_TOKEN_app_terraform_io;tf.my-company.net→TF_TOKEN_tf_my__company_net. - Getting the encoding wrong names a different host: a single underscore where the hyphen was means a period, and an extra double underscore adds a hyphen that is not in the real name.
- Environment variable credentials take precedence over credentials stored in the CLI configuration by
terraform login(credentials.tfrc.json), so a stale file token on a runner image is ignored when a valid variable is set.
Which kind of token to give the pipeline, and how to scope and protect it, belongs to securing pipeline identities; workload-identity credentials for providers belong to the sibling topic on dynamic provider credentials.
How do VCS-driven and API-driven workspaces start runs?
A VCS-driven workspace starts runs from repository events according to its trigger settings, while an API-driven workspace starts runs when a pipeline uploads a configuration version.
VCS trigger modes
A VCS-connected workspace queues runs according to one of three triggering modes, and separately runs speculative plans for pull requests.
| Setting | Queues a run when… | Notes |
|---|---|---|
| Always trigger runs | Any file in the repository changes on the tracked branch | Ignores the working directory: a docs change still runs |
| Only trigger runs when files in specified paths change | A changed file matches a trigger pattern (glob, e.g. /envs/prod/**/*) or a trigger prefix (a plain directory path) | Prefixes require the workspace to have a working directory; patterns are globs, not regular expressions |
| Trigger runs when a Git tag is published | A pushed tag matches the chosen format (semantic versioning variants or a custom regular expression) | Fires for every matching tag regardless of the VCS branch setting; ordinary merges do not run |
Path filters must include everything the root module reads. If envs/prod calls local modules with relative paths such as ../../modules/network, a pattern for envs/prod alone never runs when those modules change, even though they change what prod deploys. The VCS branch field names one branch to track; it is not a tag filter, and trigger patterns match file paths, not tag names. Release-by-tag for production is the tag trigger mode.
Pull-request plans as a merge gate
With automatic speculative plans on (the default), HCP Terraform runs a plan-only speculative run for each pull request against the tracked branch and reports the result to the VCS provider as a commit status check. Making that check required in the branch protection rules blocks merging until the plan succeeds. Speculative runs can never be confirmed or applied, so auto-apply has no effect on them, and turning automatic speculative plans off affects only pull requests, not tag or merge runs.
The API-driven workflow
In the API-driven workflow the pipeline creates a configuration version, uploads a .tar.gz of the root module to its upload URL, and HCP Terraform then queues a run (by default) or the pipeline creates one through the Runs API.
The archive's root must be the configuration directory itself: archive the contents of the directory (change into it, or use tar's -C <dir>, and archive .), for example tar -C dist -czf release.tar.gz .. Archiving the directory by name stores every entry under that directory's prefix, so Terraform finds no configuration files at the top level. Filtering which files go into the archive does not change the prefix, and the archive must keep any subdirectories the module reads.
| Configuration version attribute | Default | Effect |
|---|---|---|
auto-queue-runs | true | Queue a run as soon as the upload finishes. Set false when the script creates runs itself, or every deployment gets two runs |
speculative | false | The version can only create speculative (plan-only) runs that nobody, not even an admin, can confirm or apply: the right choice for pull-request plans |
provisional | false | The version does not become the workspace's current configuration until a run using it applies; that run can still be applied |
Workspace settings such as auto-apply decide what happens after a plan; they do not control whether a run is queued or whether it can be applied at all.
How do run tasks and approval rules gate an apply?
An apply is gated by run tasks, which let external services check a run at a chosen stage, and by approval rules, which decide whether a person must confirm the plan.
Run task stages and enforcement
Run tasks call an external service (security scanners, cost estimators, compliance tools) at a chosen stage of an HCP Terraform run, and the task's enforcement level decides whether its failure stops the run.
| Stage | Runs | Can inspect the proposed changes? |
|---|---|---|
| Pre-plan | Before Terraform creates the plan | No: only the configuration exists |
| Post-plan | After the plan, before apply | Yes |
| Pre-apply | Just before the apply | Yes |
| Post-apply | After changes are made | Too late to stop them |
- Advisory: a failure is shown as a warning and the run continues. An advisory task can never block a run.
- Mandatory: a failure stops the run. Timeouts and unexpected remote errors count as failures, not skips.
- Each task keeps its own level: attaching a mandatory task does not make another, advisory task blocking.
To scan proposed changes and block on a critical finding, attach the task at post-plan (or pre-apply) with mandatory enforcement. Writing the policies themselves (Sentinel or OPA) is a separate governance topic.
Auto-apply, manual apply and one outstanding plan
Approval is controlled per workspace: auto-apply applies a successful plan with no human step, manual apply waits for an authorised user to confirm it. HashiCorp's guidance for running Terraform in automation is to auto-approve only non-critical infrastructure, such as development or disposable sandbox environments, and to keep human review for anything whose outage matters. Being earlier in the promotion order does not make an environment non-critical: a shared staging environment that blocks every release needs review too, and a policy check is not a substitute for a human reviewer on production.
- The workspace auto-apply setting covers runs created from the UI, the API and VCS. Runs that the CLI creates through a
cloudblock ignore it and needterraform apply -auto-approve; with-input=falseand no flag, the apply errors instead of waiting. - Runs created by run triggers from other workspaces have their own, separate auto-apply setting.
One outstanding plan per state
HashiCorp recommends allowing only one plan awaiting approval per state at a time, so the plan a person reviewed is the plan that is applied. A self-hosted pipeline that lets several saved plans wait for the same state invites approvers to apply the wrong one; re-planning inside the apply job breaks the link between review and apply, and -auto-approve removes the review entirely.
HCP Terraform enforces this with its run queue: a workspace processes one non-speculative run at a time, and a run that has planned and is waiting for confirmation still holds the queue. Later runs stay Pending, and new runs queue behind it too. Speculative plans do not use the queue, so they keep completing. To move on, confirm or discard the waiting run.
How do blue/green deployments work with Terraform?
A blue/green deployment runs the current version (blue) and the new version (green) side by side and moves traffic between them at the load balancer, so release and rollback are both a routing change. HashiCorp's tutorial builds it in one configuration:
- Two instance sets, each in its own target group, with feature-toggle counts:
count = var.enable_green_env ? var.green_instance_count : 0. - One ALB listener whose
forwardaction weights the two target groups from a lookup onvar.traffic_distribution(blue,blue-90,split,green-90,green).
Weights move traffic; toggles move capacity. Changing the weight never creates or destroys instances, and setting a toggle to false makes count 0, so Terraform plans to destroy those instances (it does not stop them or merely forget them; forgetting needs a removed block or terraform state rm). count = 0 is valid.
| Goal | Apply |
|---|---|
| Release | First enable_green_env = true with all weight on blue; check green's health; then apply a green weight |
| Roll back | traffic_distribution = blue: errors stop at once and green stays up for debugging |
| Finish | traffic_distribution = green and enable_blue_env = false in one apply |
The order matters because a target group that receives weight but has no healthy instances can return errors to the users routed to it: never move weight to an environment before it exists, never remove instances while any weight still points at them, and never remove blue while it carries the traffic. The trade-off of the design is cost: both environments run, and are billed, until blue is decommissioned. Both live in one state; the toggles and weights already limit what each apply touches.
How do canary releases shift traffic gradually?
A canary release sends a small share of requests to the new version and raises it in steps (for example 10%, 50%, 100%), each step being a small, reviewed plan and apply of a weight variable. Where you set the weights decides how quickly a change takes effect.
| Weights set on | When a change reaches clients | Rollback |
|---|---|---|
Target groups in one ALB listener's forward action | Immediately for new requests: the load balancer's DNS name never changes | Move the weights back; no instances are rebuilt |
| Weighted DNS records (e.g. Route 53) in front of two load balancers | Gradually: resolvers and clients keep cached answers until the record's TTL expires | Also gradual, over roughly the TTL |
The DNS delay comes from caching for the record's TTL; it is a property of DNS itself. A load balancer's deregistration delay is a different setting that only drains targets being removed from a target group.
Listener weights have one catch: target-group stickiness in the forward action (stickiness { enabled = true duration = N }) keeps a returning client on the target group it was first sent to for N seconds. A long duration means weight changes reach returning users only after it expires; shorten the forward action's stickiness duration so shifts take effect quickly. A target group's own stickiness setting is different: it keeps a client on one target inside that group and does not decide which group the forward action picks.
A rolling replacement with create_before_destroy is not a canary: it swaps capacity in one step with no partial traffic stage.
How do immutable images and artifact promotion keep releases tested?
Immutable images make every server of a release identical and pre-tested, and promotion makes production deploy exactly the artifact a lower environment tested.
Immutable deployments
An immutable deployment never changes a running server: each release is a new, pre-tested, versioned machine image, and deploying it means creating new instances from that image ID and destroying the old ones. Because an image ID change forces replacement, Terraform does this automatically.
- Provisioners are a last resort. HashiCorp recommends building configuration into images (for example with Packer) or passing it as cloud-init/
user_data.remote-execscripts that install packages leave half-configured, tainted instances when they fail and let servers launched at different times differ. Any fix that still configures servers after launch, wherever the provisioner lives or however its failures are handled, keeps the mutable model and its drift. - On
aws_instance, a change touser_datais applied in place by default (the instance is stopped and started). Setuser_data_replace_on_change = trueso a user_data-only release also produces new instances. create_before_destroyonly orders a replacement that is already planned; it does not turn an in-place update into a replacement.ignore_changesonuser_datahides the release instead of shipping it.
Promoting tested artifacts
Promotion means production deploys the exact module version, image or commit that a lower environment tested, never whatever is newest at plan time.
| Artifact | Promote with | Avoid |
|---|---|---|
| Registry module | An exact constraint in prod, e.g. version = "2.7.1" | Any range operator (~>, >=), which also accepts later patch releases that were never tested; relying on the lock file, which records providers only, never modules |
| Machine image (no extra tooling) | A pinned image ID input changed by a reviewed promotion commit | data "aws_ami" with most_recent = true in prod; data sources are read during planning even with -refresh=false |
| Machine image tracked in HCP Packer | data "hcp_packer_artifact" reading a channel (e.g. production) that QA reassigns, so promotion needs no code commit | Reading the latest channel in prod |
| Configuration | A Git tag on a tested commit with a tag-triggered prod workspace | Deploying prod from every merge to main |
Dev and test environments can keep following the newest artifact. The general rule: a gate must record which artifact passed testing. Conditions that do not (for example on an artifact's age), or settings that freeze every change, either let untested artifacts through or block approved ones.
How do you roll back a bad Terraform deployment?
Terraform has no undo: you roll back infrastructure by applying the last known-good configuration, normally by reverting the change in version control and letting a normal reviewed run apply the reverted commit. In blue/green or canary designs, rollback is moving the weights back.
HCP Terraform's state rollback is not an infrastructure rollback. It replaces the state record only:
- It requires permission to create state versions and a locked workspace.
- It duplicates the chosen earlier state version and makes the copy the current state version; later versions are kept, so the rollback can itself be undone.
- The workspace stays locked afterwards until someone unlocks it.
- It changes the state record, not the resources. HashiCorp cautions that a rolled-back state may no longer match the real infrastructure, so after a bad apply it leaves the infrastructure broken and the record wrong; it is meant for recovering from a corrupted or wrongly written state.
The same principle rules out other shortcuts: anything that re-applies the current, broken configuration (including forced replacement) reproduces the problem, and tearing production down to rebuild it turns a bad release into an outage. Runs cannot be replayed from history; the known-good configuration has to be applied again.
Tip. Expect production scenarios decided by one stated constraint: the job must stay read-only, runners share a host or have no internet, output is piped to another tool, a run must be impossible to apply, prod must deploy only on a tag or only a tested artifact, traffic must shift without DNS delay, or green must stay up for debugging. Some items show a short HCL block, a pipeline expression or a command and ask what happens or what to change. Wrong options are usually real flags and settings that solve a neighbouring problem (for example two configuration-version attributes, two enforcement levels or two ways of caching providers), and several items ask you to select two correct statements.
- terraform fmt -check -recursive is the read-only formatting gate; add -diff for useful logs. Without -recursive, subdirectories are skipped; without -check, fmt rewrites files.
- The plugin cache is not concurrency safe and is used only for packages that match lock-file checksums; offline runners need -plugin-dir or a filesystem_mirror-only provider_installation.
- setup-terraform's default wrapper exposes stdout, stderr and exitcode outputs but corrupts piped output; set terraform_wrapper: false for redirection.
- TF_TOKEN_<host> encodes periods as _ and hyphens as __, and beats credentials written by terraform login.
- VCS triggers: always (any file), specified paths (globs or prefixes, prefixes need a working directory, include local modules), or Git tags (every matching tag, regardless of branch).
- API-driven configuration versions: archive the directory's contents; auto-queue-runs defaults to true; speculative versions can never be applied.
- Run tasks block only when mandatory, and timeouts count as failures; inspect changes at post-plan or pre-apply.
- Auto-apply covers UI, API and VCS runs, not CLI runs (-auto-approve); auto-approve only non-critical environments and keep one outstanding plan per state.
- Blue/green and canary: weights move traffic instantly at the load balancer, toggles move capacity; create, verify, shift, then decommission. DNS weights wait on TTLs, forward-action stickiness delays returning users.
- Promote exact, tested artifacts (exact module versions, pinned image IDs or HCP Packer channels) and roll back by applying the last good configuration; HCP state rollback changes state only.
Frequently asked questions
Which terraform fmt command should a CI job run?
terraform fmt -check -recursive from the repository root. -check makes fmt read-only and exit non-zero when any file is not in canonical format, and -recursive includes subdirectories such as modules/. Add -diff if developers need the log to show the changes each file needs.
Can several CI jobs share one Terraform plugin cache directory?
Not when they run terraform init at the same time. The provider plugin cache is not guaranteed to be concurrency safe, so concurrent jobs should each use their own cache directory. Commit .terraform.lock.hcl as well, because Terraform uses a cached provider only when it matches a checksum recorded in the lock file.
Why does terraform output -json produce invalid JSON in GitHub Actions?
hashicorp/setup-terraform installs a wrapper around the terraform binary by default, and the wrapper adds its own lines around the command's output. Set the action's terraform_wrapper input to false in jobs that redirect or pipe Terraform's output.
How do I pass an HCP Terraform token to the CLI in a pipeline?
Set an environment variable named TF_TOKEN_ followed by the host name with each period replaced by an underscore, for example TF_TOKEN_app_terraform_io. A hyphen in the host name can be written as a double underscore. Environment variable credentials take precedence over credentials saved by terraform login.
Why does auto-apply not apply my CLI-driven run in HCP Terraform?
The workspace auto-apply setting covers runs started from the UI, the API and VCS. Runs that the Terraform CLI starts through a cloud block need terraform apply -auto-approve; without it, a non-interactive apply cannot be confirmed.
Is blue/green or canary better for Terraform deployments?
Both can use the same building blocks: two environments and weighted target groups on one load balancer listener. Blue/green switches all traffic at once and rolls back by switching back; canary moves traffic in small reviewed steps to limit the impact of a bad release. Either way both environments run, and cost money, until the old one is decommissioned.
Does rolling back state in HCP Terraform undo an apply?
No. State rollback copies an earlier state version and makes it current in a locked workspace, but it queues no run and changes no infrastructure. To undo a bad change, revert the configuration in version control and apply it through a normal reviewed run.
How do I make production use only a tested module or image?
Pin it. Use an exact module version constraint such as version = "2.7.1" (the lock file does not pin modules), a pinned image ID updated by a reviewed promotion, or an hcp_packer_artifact data source that reads a channel QA reassigns. Never let production resolve the newest image at plan time.
Source
This lesson covers the "CI/CD and automation workflows" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.