Standalone lab

Build Infrastructure With GitHub Copilot and Terraform

Write a validated Terraform module with Copilot without ever running apply — using variable validation, plan review and a security pass to catch what valid HCL does not.

  • Intermediate
  • Medium lab
  • 4 min read
Written by
The Copilot Stack Editorial Team
Published
Updated
Last technically verified

What you will be able to do

  • Write a Terraform module with Copilot inside conventions you set first
  • Use variable validation blocks to reject bad input at plan time rather than apply time
  • Read a plan for the changes that force replacement, not just the resource count
  • Identify the security defaults Copilot will choose unless told otherwise
  • Validate infrastructure code without provisioning anything

Before you start

  • Terraform 1.9 or later, or OpenTofu 1.7+
  • An editor with GitHub Copilot enabled
  • No cloud account and no credentials — this lab deliberately provisions nothing

Preparation from the Academy: GitHub Copilot for Terraform, GitHub Copilot for OpenTofu, GitHub Copilot for DevOps Engineers

/labs/terraform-with-copilot/

Copilot writes syntactically valid HCL very reliably. That is the problem: valid HCL is not safe infrastructure, and terraform validate passing tells you the configuration parses, not that it is a good idea.

This lab builds a small module and spends its time on the checks that catch the difference.

Step 1 — Set the conventions before writing HCL

mkdir copilot-tf && cd copilot-tf && mkdir -p .github/instructions

.github/instructions/terraform.instructions.md:

---
applyTo: "**/*.tf"
---

# Terraform conventions

- Five files by role: versions.tf, providers.tf, variables.tf, main.tf, outputs.tf.
- Pin `required_version` for Terraform itself, not only providers.
- Provider constraints use `~>`, never `>=`. `>=` means the next major release
  lands in the next `init`.
- Every variable has a `description`. Every variable with a valid range has a
  `validation` block.
- Never write a credential into a .tf file.
- `for_each` over `count` for anything keyed. `count` re-indexes on removal and
  destroys the wrong resource.
- Compute tags once in `locals` and merge.
- Never suggest `terraform apply`.

Step 2 — Ask for the module

Copilot prompt

Create a Terraform module following this repository’s conventions. It should take a project name, an environment, and a map of named artefacts, and write one JSON manifest file per artefact using the local provider. Use random_id for a suffix. No cloud providers.

The local and random providers are chosen deliberately: the module is initialisable and validatable by anyone, with no account, and it exercises every convention above.

Step 3 — Check the four things Copilot gets wrong

Read the generated files against this list. In our runs, at least two of these are wrong on the first pass.

1. Version constraints. Look for >= anywhere:

grep -n 'version *= *">=' *.tf

Any hit is a future breakage. ~> 3.6 allows 3.7; >= 3.6 allows 4.0.

2. Missing validation blocks. Every variable with a valid range needs one:

grep -c 'validation {' variables.tf

Compare that against the number of variables. A project_name that ends up in a resource name must be constrained, or the failure arrives at apply time in the middle of a change set.

3. count where for_each belongs. Search for it:

grep -n 'count *=' *.tf

If artefacts are keyed by name and indexed with count, removing the first one re-indexes every subsequent resource — Terraform will plan to destroy and recreate resources you did not touch.

4. random_id without keepers. Without them, the suffix regenerates on unrelated changes and cascades a replacement through everything downstream.

Fix each with a targeted follow-up:

Copilot prompt

Change the provider constraints from >= to ~>, add a validation block to project_name requiring 3-32 lowercase alphanumeric or hyphen characters starting with a letter, and switch the artefact resources from count to for_each keyed by the map key. Change nothing else.

Step 4 — Read a plan properly

terraform init -backend=false
terraform plan -out=tfplan
terraform show -no-color tfplan > plan.txt

The number of resources is the least interesting line in a plan. These are the ones that matter:

# Anything being destroyed or replaced
grep -nE '# .* will be (destroyed|replaced)' plan.txt

# What forces a replacement
grep -n 'forces replacement' plan.txt

Copilot is genuinely good at summarising a long plan. Ask it to:

Copilot prompt

Read plan.txt. List every resource being destroyed or replaced, and for each, the specific attribute change that forces it. Ignore additions.

Step 5 — The security pass

Ask explicitly, because it will not volunteer this:

Copilot prompt

Review these .tf files for security problems: credentials in source, overly permissive access, missing encryption, public exposure, and state file handling. For each finding give the file, the line, and what specifically goes wrong.

Then check the one thing that is nearly always missing — .gitignore:

cat .gitignore

It must contain at minimum:

.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example

Validation

All four must pass:

terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform plan -out=tfplan

Then prove the validation block actually rejects bad input:

terraform plan -var 'project_name=Invalid_Name' 2>&1 | grep -i "invalid value"

That must produce your error message. If the plan succeeds, the validation block is missing or its regex is wrong — and you have learned that a validation block you never tested is a validation block you do not have.

Troubleshooting

terraform init tries to reach a backend. Use -backend=false. This module has no remote state and needs none.

fmt -check fails on files Copilot wrote. Run terraform fmt -recursive. Formatting is not worth a follow-up prompt.

validate passes but plan fails. validate checks the configuration in isolation; plan resolves variables and provider schemas. A missing required variable only shows at plan.

The plan wants to replace everything on a trivial change. random_id has no keepers. Add them keyed to the inputs that should genuinely force a new suffix.

Cleanup

Nothing was provisioned, so there is nothing to destroy — but the local files matter:

rm -f tfplan plan.txt
rm -rf .terraform generated
cd .. && rm -rf copilot-tf

What you should take away

Copilot generated valid HCL in one pass and got the version constraints, the validation blocks and the iteration construct wrong — three defects that a terraform validate pass will never surface, because they are all valid configuration.

The checks that found them were four grep commands and a plan you actually read. That is the whole method, and it does not change when the module gets big.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources

All labs