Skip to content

GitHub Copilot CLI for Terraform

Lesson 7 of 9Advanced13 min readGitHub Copilot & AI Engineering · Copilot CLIVerified: Terraform CLI conventions and GitHub Copilot CLI permission model, September 2026

Terraform is unusually well suited to AI assistance: the configuration is declarative, the provider documentation is extensive, and there is a built-in preview — the plan — that makes the effect of a change visible before it happens.

It is also the domain where a mistake is least reversible. Re-applying a previous configuration does not undo a destroy: a deleted database is gone, a released address may belong to somebody else, and a recreated resource is a different resource with the same name.

Provider syntax. Resource arguments, nested blocks, the exact attribute name. Extensive documentation exists, nobody remembers it, and terraform validate verifies the answer in a second — which makes this the ideal shape for AI assistance.

Reading a plan. The highest-value use, and the one this lesson spends most time on — a plan is dense, long, and contains one line that matters among a hundred that do not.

Module structure. Variable design, outputs, and what belongs in a module versus a root configuration. This is design advice rather than syntax, so treat it as options to choose between rather than an answer — a module’s interface is a decision with consequences you will live with.

Security review. Open security groups, unencrypted storage, wildcard IAM policies, missing prevent_destroy on stateful resources — pattern-matching against a checklist, which is exactly what a systematic reader does better than a human skimming a long configuration.

Explaining someone else’s configuration. A module you inherited, with variables whose purpose is not obvious and a count expression nobody wants to unpick. Reading comprehension with a checkable answer, which is the safest category of AI assistance.

Refactoring. Extracting a module, renaming resources, restructuring — where moved blocks matter and getting them wrong destroys and recreates production infrastructure rather than renaming it.

Terraform’s shape makes the discipline from this cluster unusually explicit, which is why it is the clearest example of it:

Generate → validate → plan → read the plan → decide → apply (elsewhere)

The agent belongs in the first four steps and nowhere else. Generating and validating are cheap and checkable; reading a plan is where it adds the most value; deciding is yours; and applying is a pipeline’s job.

Every other technology in this cluster has the same loop with a weaker preview. Terraform’s is first-class, which is what makes the boundary easy to see here and worth carrying to the domains where it is fuzzier.

The single most valuable thing an agent does here, and the place to be most careful about what its summary means.

Terminal window
terraform plan -out=tfplan
terraform show tfplan

What it doesProduces a plan and writes it to a file, then renders that file as text.

Why we run itA saved plan is the artefact you review and then apply — applying a saved plan guarantees you applied what you read.

Expected resultA resource-by-resource summary and a line counting changes to add, change and destroy.

The summary line is the first thing to read:

Plan: 3 to add, 2 to change, 1 to destroy.

Any non-zero destroy count deserves an explanation. Ask for it specifically:

This plan destroys one resource. Which one, why is it being destroyed rather than updated, and what data or state does it hold?

Terraform destroys and recreates when an immutable attribute changes. That is frequently fine — a security group rule — and occasionally catastrophic — a database instance, a persistent disk. The plan says what will happen; only you know which category the resource is in.

For a machine-readable plan the agent can analyse precisely:

Terminal window
terraform show -json tfplan > plan.json
Terminal window
{/* Which resources would be destroyed */}
jq -r '.resource_changes[]
| select(.change.actions | index("delete"))
| .address' plan.json

That is deterministic and exact, and it is a better basis for a question than a prose summary of a long plan.

“What is being destroyed, and is any of it stateful?” The question that matters most.

“Which changes are replacements rather than updates?” A replacement is a destroy plus a create, with downtime and a new identifier.

“Does anything here affect resources outside this module?” Implicit dependencies produce changes somebody did not expect.

“What would the blast radius be if this were wrong?” Which systems depend on the resources being changed.

“Is anything changing that I did not touch?” Provider version drift, upstream changes, or somebody else’s manual modification being reverted. This last category is worth flagging — a plan reverting a manual change is telling you somebody made one.

The failure modes are specific.

Invented arguments. An attribute that sounds right and does not exist on that resource. terraform validate catches it instantly, which is why validating is not optional.

Old provider syntax. Provider major versions rename and restructure arguments. Generated configuration reflects whatever was common, which may be two versions behind.

Insecure defaults. Public access, no encryption, permissive IAM. Not because the model prefers insecure configurations, but because minimal examples in documentation omit hardening.

Missing lifecycle protections. Nothing generated will include prevent_destroy on your database unless you ask.

The validation loop:

Terminal window
terraform fmt -check
terraform validate
terraform plan

All three are read-only and safe to pre-approve. Ask the agent to run them and fix what they report — which is the observe step doing genuine work.

The area where an agent should be most constrained, because state operations are not covered by the plan preview.

Safe to run: terraform state list, terraform state show, terraform show.

Never from an agent session: terraform state rm, terraform state mv, terraform import, terraform taint, and anything writing to remote state.

The reason is specific: state operations change Terraform’s model of reality without changing reality. A state rm makes Terraform forget a resource that still exists — the next plan proposes creating it again, and you end up with two. There is no plan preview for this, and the recovery involves editing state, which is worse.

Where a state operation is genuinely needed — a refactor, a resource moved between modules — prefer moved blocks in configuration, which are declarative, reviewable in a pull request, and visible in the plan.

moved {
from = aws_instance.app
to = module.compute.aws_instance.app
}

That is the reviewable version of a state mv, and it is what an agent should propose.

A genuinely good use, because the checks are pattern-shaped and the checklist is stable.

.github/agents/terraform-reviewer.md
---
name: terraform-reviewer
description: Reviews Terraform for security and operational risk. Read-only.
tools: ["read", "search"]
---
Review the Terraform you are given, in this order:
1. Ingress open to 0.0.0.0/0, and on which ports.
2. Storage without encryption at rest.
3. IAM policies with wildcard actions or wildcard resources.
4. Resources without the tags our policy requires.
5. Stateful resources without `prevent_destroy`.
6. Public accessibility on anything that should be private.
7. Secrets in variables without `sensitive = true`, or hardcoded in configuration.
For each finding: file, resource address, why it matters, and your confidence.
If a finding depends on a file you have not read, say so.
Do not modify anything.

The read-only tool list is the point. A security reviewer that can edit is one that can “fix” something in a way nobody reviewed, in a language where the fix might destroy a resource.

Pair it with the deterministic scanners from Terraform CI — config scanning finds what a checklist finds, reliably, on every pull request.

Two questions a plan does not answer and an agent can help with, provided the limits are clear.

“What will this cost?” A model can identify that a plan adds three instances of a given type and reason about the order of magnitude. It cannot know your committed-use discounts, your negotiated rates, or your existing utilisation. Treat the answer as a prompt to check with a cost estimation tool rather than as a number — Terraform CI covers running one in the pipeline, which produces figures rather than estimates.

“Will this hit a limit?” Account quotas, subnet address space, instance availability in a zone. An agent reading the plan alongside your existing state can flag candidates — “this adds 40 addresses to a /26 subnet” is arithmetic it does reliably. Whether the quota is actually exhausted requires querying the provider.

Both are cases where the agent narrows and a deterministic tool answers, which is the recurring shape in this cluster. The value is in knowing which questions to ask, and a plan review that surfaces “this will exhaust the subnet” before the apply is worth having even when the confirmation comes from somewhere else.

The operation where Terraform’s declarative model is most likely to surprise, and where an agent genuinely helps.

The problem. Renaming a resource, or moving it into a module, changes its address. Terraform sees a resource that no longer exists at the old address and a new one at the new address, and plans to destroy and create — which for a database is not a rename.

The mechanism. moved blocks tell Terraform the resource is the same one under a new name.

moved {
from = aws_db_instance.main
to = module.database.aws_db_instance.primary
}

The check. After adding the blocks, the plan should show no changes for the moved resources. If it still shows a destroy, a block is missing or wrong.

I am moving these resources into a module. Write the moved blocks, then run terraform plan and confirm nothing is destroyed. If anything still shows as destroy-and-create, tell me which block is missing.

That is a task an agent does well — it is mechanical, the correctness condition is checkable, and the plan tells you whether it worked. It is also a task where doing it by hand across thirty resources is tedious enough that people skip the blocks and accept the recreation, which is how a refactor becomes an outage.

Two categories of “the plan changed and I did not change anything”.

Provider upgrades. A new provider major version can introduce defaults, rename arguments, or compute attributes differently — producing a plan full of changes on a configuration nobody touched. Pinning matters:

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

The dependency pinning argument applies: pin, and update deliberately with the plan reviewed as a change in its own right.

Manual drift. Somebody changed something in the console. The plan proposes reverting it, which may be correct or may be undoing an emergency fix nobody wrote down.

This plan changes a resource I did not touch. Is this drift from a manual change, or a provider version difference? What does the current state say about the attribute in question?

Distinguishing the two matters: reverting drift is usually right; reverting an emergency fix during a routine apply is how an incident recurs.

Where AI is useful for design rather than syntax.

Variable design. Asking “what should be configurable here and what should be fixed?” produces a reasonable starting point. The tell of a badly-designed module is a variable for everything, which makes it a worse version of writing the resources directly.

Validation blocks. Generated readily and rarely thought of:

variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}

Outputs. What a module should expose. A module returning its entire resource object is coupling; one returning three specific attributes is an interface.

Documentation. Module README generation from variables and outputs is derivable and worth automating — with the caveat from AI repository documentation that it should be derived from the actual files.

The division between what belongs in a session and what belongs in CI, stated concretely because it is the whole safety argument.

ActivityWhere
Writing configurationSession, with validate and plan
Explaining a planSession
Security reviewSession, plus deterministic scanning in CI
Refactoring with moved blocksSession, verified by plan
ApplyingPipeline, with approval
State operationsNeither — a deliberate, documented, human operation

What the pipeline provides that a session cannot:

State locking. Two applies at once corrupt state. A pipeline serialises; two people with terminals do not.

An audit trail. Who applied what, when, from which commit. A session leaves nothing.

Approval gates. Environments with required reviewers make production a separate permission from writing configuration.

Plan-then-apply on the same artefact. CI plans on the pull request and applies the saved plan on merge, which guarantees the applied change is the reviewed one.

Scoped credentials. OIDC with a role scoped to the task, expiring in minutes, rather than whatever is in your shell.

That last one is the strongest argument and the one most often overlooked. An interactive apply uses your credentials, which are broader and longer-lived than a pipeline’s — so the same operation is strictly more dangerous from a terminal, independent of who or what proposed it.

A plan touching two hundred resources is not something to read line by line, and it is exactly where a summary is both most useful and most likely to omit the thing that matters.

The approach that works:

Get the structured form.

Terminal window
terraform show -json tfplan > plan.json

Ask precise questions of it rather than for a summary:

Terminal window
{/* Counts by action */}
jq -r '.resource_changes[].change.actions | join(",")' plan.json | sort | uniq -c
Terminal window
{/* Anything being replaced */}
jq -r '.resource_changes[]
| select(.change.actions == ["delete","create"] or .change.actions == ["create","delete"])
| .address' plan.json
Terminal window
{/* Anything being deleted outright */}
jq -r '.resource_changes[]
| select(.change.actions == ["delete"])
| .address' plan.json

Those are deterministic and complete. The agent’s role is then answering questions about the specific resources those queries surfaced — “this one is being replaced; what does the replacement destroy?” — rather than summarising two hundred changes into a paragraph that necessarily omits most of them.

The general principle, which applies beyond Terraform: when the input is large and structured, query it deterministically and use the agent on the results. A prose summary of structured data is a lossy transformation, and the loss is not visible in the output.

Applying from a session. The operation with the least reversibility, outside every control.

Reading the summary instead of the plan. “3 to add, 1 to destroy” needs the destroy explained.

Not validating generated configuration. Invented arguments are caught in seconds by validate.

Accepting generated defaults. Documentation examples are minimal, not hardened.

State operations from an agent. No plan preview, and the recovery is worse than the problem.

Missing prevent_destroy on stateful resources. Nothing generated adds it unprompted.

Ignoring a plan that changes something you did not touch. That is drift, or somebody’s manual change being reverted.

Trusting a plan summary of a long plan. For anything large, use -json and query it.

Path-specific instructions scoped to **/*.tf are unusually high-value here, because Terraform has a large number of conventions that are not inferable and a large number of insecure-by-default examples in the wild.

.github/instructions/terraform.instructions.md
---
applyTo: "**/*.tf"
---
- Pin provider versions with `~>`. Never leave a provider unpinned.
- All storage is encrypted at rest. All buckets block public access.
- IAM policies name specific actions and resources. No wildcards.
- Every resource carries the tags in `locals.common_tags`.
- Stateful resources — databases, volumes, buckets with data — have
`lifecycle { prevent_destroy = true }`.
- Variables holding secrets are marked `sensitive = true`.
- Use `moved` blocks for renames and module moves. Never propose `terraform state mv`.
- Never propose `terraform apply`, `destroy`, `import`, `taint` or any
`state` subcommand that writes.

The last two lines are the ones specific to agent use, and they address the two failure modes this lesson is built around. As always, they shape rather than enforce — a preToolUse hook matching terraform apply and denying it is what makes it impossible, and for a team doing this regularly that hook is worth the twenty minutes.

The rest of the list is worth having regardless of AI: it is the hardening checklist, applied at the point of writing rather than at review.

Terraform gives you a preview of a change before making it, which is a gift. An agent is excellent at explaining that preview and must not be the thing that decides to accept it — because accepting it is the one action here that cannot be undone.

  • Apply belongs in a reviewed pipeline, not an interactive session
  • A saved plan applied later guarantees you applied what you reviewed
  • Any non-zero destroy count needs a specific explanation of what and why
  • terraform show -json plus jq gives exact answers rather than a prose summary
  • validate and fmt -check catch invented arguments and are safe to pre-approve
  • Generated configuration reflects minimal documentation examples, not hardened defaults
  • State operations have no plan preview; prefer moved blocks, which are reviewable
  • A read-only reviewer agent with an explicit checklist is the durable form of security review

Use a disposable Terraform configuration against a local or free-tier provider.

  1. Ask for an S3 bucket configuration with no further qualification. Predict: is encryption on? Is public access blocked?

  2. Ask again specifying the hardening requirements. Compare.

  3. Run terraform validate on the first version. Predict: are all the arguments real?

  4. Make a change that forces replacement — alter an immutable attribute. Run terraform plan. Predict: does the summary make it obvious that a resource is destroyed?

  5. Ask the agent to explain the plan. Predict: does it identify the replacement and say what is lost?

  6. Produce terraform show -json and query it with jq for deletions. Compare precision with the prose summary.

  7. Ask it to move a resource into a module. Predict: does it propose state mv or a moved block?

  8. Destroy the configuration when finished.

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.