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
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
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
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 *= *">=' *.tfAny 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.tfCompare 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 *=' *.tfIf 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:
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.txtThe 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.txtCopilot is genuinely good at summarising a long plan. Ask it to:
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:
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 .gitignoreIt must contain at minimum:
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.exampleValidation
All four must pass:
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform plan -out=tfplanThen 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-tfWhat 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
Finished the lab?
Stored in this browser. It appears on your progress dashboard, and syncs across devices if you sign in.
Was this lesson helpful?
We record which lesson you rated and whether it helped. Nothing identifies you — no account, no cookie, no session.