GitHub Copilot for Terraform
Terraform gives you the strongest local validation of any technology in this
cluster. terraform validate type-checks variables, resolves every reference,
and rejects an attribute that does not exist on a resource — so a hallucinated
argument fails on your machine in under a second rather than at apply time.
That is a genuine advantage and it is narrower than it sounds. Here is what the two tools said about the same first-draft configuration:
terraform validate Success! The configuration is valid.
checkov 6 passed, 18 failedThe eighteen included hard-coded AWS credentials in the provider block, a
security group open to 0.0.0.0/0 on every TCP port, an S3 bucket with no
public access block, and subnets assigning public IPs by default. All of it is
valid HCL. validate answers “will Terraform understand this”, not “should this
exist”.
Key takeaways
terraform validatechecks the configuration against the provider schema. It says nothing about whether the infrastructure is safe. Run a policy scanner too.- The number to read in a plan is the destroy count, and the phrase to search
for is
forces replacement. A replacement is a delete followed by a create. for_eachovercount:countis positional, so removing the second of three items re-indexes and destroys the third.- Never let credentials into a
.tffile. Checkov’sCKV_AWS_41catches this one, which is more than most linters manage for secrets. - Suppress a scanner finding inline with a reason, and put the comment inside the resource block — Checkov attributes findings to a line range, so a comment above the block is ignored.
What Copilot is good at here
Terraform is unusually well suited to AI assistance, for a specific reason: the
provider schema is large, regular, and something nobody memorises. Remembering
that an aws_s3_bucket_lifecycle_configuration rule needs a filter block even
when the filter is empty is exactly the kind of detail a model holds and you do
not.
Reliably good: resource blocks for a provider you have named, converting console
clicks or CLI invocations into HCL, writing variables.tf with descriptions and
validation blocks, expressing a repetitive resource with for_each, and
explaining a module you inherited.
Reliably needs correcting: anything that is a policy decision. Public access, encryption, IAM scope, deletion protection, retention, and the version constraints. The pattern from the pillar holds — the structural output is good and the defaults trend permissive.
The draft, and the eighteen findings
provider "aws" {
region = "us-east-1"
access_key = "AKIAXXXXXXXXXXXXXXXX"
secret_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
map_public_ip_on_launch = true
}
resource "aws_security_group" "web" {
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}Checkov’s eighteen findings on that file included:
CKV_AWS_41 hard coded AWS access key and secret key in provider
CKV_AWS_260 security group allows ingress from 0.0.0.0:0 to port 80
CKV_AWS_24 security group allows ingress from 0.0.0.0:0 to port 22
CKV_AWS_130 VPC subnets assign public IP by default
CKV2_AWS_11 VPC flow logging is not enabled
CKV2_AWS_12 default security group does not restrict all traffic
CKV_AWS_23 security group rule has no descriptionThree structural problems in that draft are worth naming beyond the scan.
Hard-coded credentials. The most serious, and the one Checkov does catch. Credentials belong in the environment or an assumed role, never in a file that gets committed.
count with an index-derived CIDR. 10.0.${count.index}.0/24 works and it
is fragile: the subnets are addressed positionally, so removing the middle one
renumbers the third and Terraform will destroy and recreate it.
A hard-coded region. us-east-1 appears in generated Terraform far more
often than it appears in real infrastructure, because it is the default in
examples.
Practical project: a reviewable VPC
Practical example
A three-AZ VPC with flow logs and a default-deny security group
Configuration that passes fmt, validate and a policy scan, with every scanner disagreement documented inline.
- Status
- Tested implementation
- Runtime
- Terraform v1.15.9, hashicorp/aws v6.61.0, Checkov 3.3.13
- Command
terraform fmt -check -recursive && terraform init -backend=false && terraform validate && checkov -d . --framework terraform- Result
- fmt exit 0; init succeeded with no state touched; validate: Success! The configuration is valid. Checkov: 51 passed, 0 failed, 4 skipped — against 6 passed, 18 failed on the draft.
- Run on
- August 21, 2026
Files
copilot-terraform-demo/ ├── versions.tf Terraform and provider constraints ├── providers.tf region and default tags — no credentials ├── variables.tf typed, described, validated ├── main.tf VPC, subnets, routing, flow logs, KMS, IAM ├── outputs.tf IDs keyed by availability zone ├── terraform.tfvars.example ├── draft/main.tf the first suggestion, kept for comparison └── .github/ └── instructions/ └── terraform.instructions.md
The prompt
Create Terraform for an AWS VPC with three public and three private subnets across three availability zones.
Requirements:
- No credentials in any file. The provider reads from the environment.
- Variables for the CIDR and the AZ count, with validation blocks.
- Derive subnet CIDRs with cidrsubnet() from the VPC CIDR. Do not hard-code them.
- Use for_each keyed by availability zone, not count.
- map_public_ip_on_launch = false.
- Enable VPC flow logs to CloudWatch, encrypted with a customer-managed KMS key with rotation enabled.
- Empty the default security group.
- Outputs for the VPC ID and subnet IDs keyed by AZ.
Then run terraform fmt, terraform validate and checkov, and show me the output.
Deriving rather than hard-coding
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.availability_zone_count)
public_subnets = [
for i in range(var.availability_zone_count) : cidrsubnet(var.vpc_cidr, 4, i)
]
private_subnets = [
for i in range(var.availability_zone_count) :
cidrsubnet(var.vpc_cidr, 4, i + var.availability_zone_count)
]
}Changing vpc_cidr now moves the subnets with it. In the draft, changing the VPC
CIDR would have left three subnets pointing at a range the VPC no longer covers —
a configuration that still validates and fails at apply.
for_each, not count
resource "aws_subnet" "public" {
for_each = { for i, cidr in local.public_subnets : local.azs[i] => cidr }
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = each.value
# Instances do not get a public IP simply for being in a public subnet.
map_public_ip_on_launch = false
tags = {
Name = "${local.name}-public-${each.key}"
Tier = "public"
}
}The resources are now addressed as aws_subnet.public["eu-west-1a"] rather than
aws_subnet.public[1]. Remove a zone and Terraform destroys exactly that
subnet; with count, removing the middle element renumbers everything after it
and Terraform destroys and recreates the tail.
This is the single most valuable Terraform-specific correction to make to generated code, because the failure only appears later, in a plan nobody expected to contain destroys.
The default security group
# The default security group of a new VPC allows all traffic between its own
# members. Leaving it in place means anything that forgets to specify a group
# gets that behaviour. Emptying it makes the default deny.
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${local.name}-default-deny"
}
}A resource block with no ingress or egress removes every rule. This is one of
the few places where the absence of configuration is the configuration, and no
suggestion produces it unprompted.
Observed output
$ terraform fmt -check -recursive
(exit 0)
$ terraform init -backend=false
Terraform has been successfully initialized!
+ provider registry.terraform.io/hashicorp/aws v6.61.0
$ terraform validate
Success! The configuration is valid.
$ checkov -d . --framework terraform
passed=51 failed=0 skipped=4Validation
Validate before deploying
Terraform: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Format | terraform fmt -check -recursive | PASSExit 0; no files needed reformatting |
| Initialise without a backend | terraform init -backend=false | PASSInitialised with hashicorp/aws v6.61.0; no state touched |
| Validate | terraform validate | PASSSuccess! The configuration is valid. |
| Security scan | checkov -d . --framework terraform | PASS51 passed, 0 failed, 4 skipped on the module; 6 passed, 18 failed on the draft |
| Plan | terraform plan | NOT RUNRequires cloud credentials and contacts a provider API. Not run: no credentials exist here and none should. |
terraform init -backend=false is the flag that makes this usable in CI. It
downloads provider schemas — which validate needs, because it type-checks
against them — without configuring a backend or touching state. A pipeline can
run it with no credentials at all.
When a security fix adds findings
This happened while writing the example and it is worth reporting, because it is the kind of thing that makes people stop running scanners.
The first scan reported two findings, one of which was CKV2_AWS_64: Ensure KMS key Policy is defined. Adding a key policy — a real improvement — produced this:
before the fix 42 passed, 2 failed
after the fix 51 passed, 4 failedThe three new findings were CKV_AWS_109, CKV_AWS_111 and CKV_AWS_356, all
complaining that the policy grants kms:* on Resource: "*".
In a KMS key policy, Resource: "*" means this key — the policy is
attached to the key, and that is the documented form. Checkov cannot distinguish
a key policy from an identity policy, so it reads the required syntax as an
unconstrained wildcard. It is a false positive.
The correct response is not to remove the key policy, and not to ignore the scanner. It is to suppress the finding with a stated reason:
data "aws_iam_policy_document" "logs_kms" {
# The suppressions have to live *inside* the block: Checkov attributes a
# finding to the resource's line range, so a comment above the block is
# outside it and is ignored.
#
# checkov:skip=CKV_AWS_109:Resource "*" in a KMS *key* policy means "this key".
# checkov:skip=CKV_AWS_111:Same — the statement is scoped by where it is attached.
# checkov:skip=CKV_AWS_356:Same. Checkov cannot tell a key policy from an
# identity policy, so it reads the required form as an unconstrained wildcard.Reading a plan
terraform plan is the most important artefact in this lesson and the one most
often skimmed. It was not run for this example — it requires credentials and
contacts the provider API — but the method matters regardless.
Read the summary as three numbers. Plan: 4 to add, 2 to change, 1 to destroy. If the destroy count is not what you expected, stop and find out why
before anything else.
Search for forces replacement. Terraform annotates the specific attribute
responsible. A replacement is a destroy followed by a create; for a database, a
bucket with a globally unique name, or anything holding data, that is the
expensive case. Attributes that force replacement are frequently innocuous
looking — a name, an availability zone, a subnet ID.
Treat (known after apply) with suspicion where it is unexpected. Fine for
an ARN. Alarming for a count or a CIDR, because it means Terraform cannot
determine the value from configuration and something upstream is dynamic.
Look for what is missing. A change you expected and cannot find usually means the resource lives in a module you did not edit, or state has drifted from reality and Terraform thinks the work is already done.
Variables, locals, outputs and expressions
The parts of HCL that Copilot handles well, and the specific things to check in each.
Variables are an interface, and validation is the cheapest gate you have.
A variable with a description, a type and a validation block rejects a bad value
at plan time with your own error message, rather than at apply time with the
provider’s. Generated variables have a type and rarely anything else. The
validation blocks in this example — a project name regex, an environment
allow-list, a CIDR check with can(cidrnetmask(...)), an AZ count range — cost
four lines each and turn a class of misconfiguration into a local error.
sensitive = true is narrower than it looks. It stops the value appearing in
CLI output and in plan diffs. It does not encrypt state, it does not stop the
value reaching a resource attribute that is itself printed, and it propagates
awkwardly through outputs. Treat it as noise reduction, not as a control.
Locals are for derived values, not for aliases. A local that simply renames a
variable adds a level of indirection. A local that computes something — the
subnet list above, a naming convention, a merged tag map — earns its place. This
is a distinction generated code frequently gets backwards, producing a locals
block of six aliases and then hard-coding the interesting computation inline.
Expressions are where the model is genuinely useful and worth checking
carefully. for expressions, cidrsubnet, try, coalesce, merge,
splat syntax and dynamic blocks are all things you look up. Copilot produces them
quickly and the failure mode is subtle: an expression that produces a plausible
value rather than an error. cidrsubnet(var.vpc_cidr, 4, i) with the wrong
newbits yields valid CIDRs of the wrong size, and nothing complains until the
subnets overlap.
The check that catches most of it: terraform console. It evaluates
expressions against your variables and state without planning anything, so you
can paste a generated expression in and see what it actually produces.
> cidrsubnet("10.0.0.0/16", 4, 0)
"10.0.0.0/20"That is five seconds and it is the difference between reading an expression and knowing what it evaluates to.
Outputs are a public interface and a leak surface. Anything a module outputs is stored in state and readable by every consumer. Generated modules output things liberally — including connection strings and generated passwords, which should never be there.
Terraform in CI
The validation sequence in this lesson is designed to run without credentials, which is what makes it usable on a pull request from a fork.
The pipeline shape that works, and the reasoning for each step:
fmt -check fails rather than reformats. A job that rewrites files needs
write access to the branch; a job that fails on unformatted input needs nothing.
init -backend=false avoids the backend entirely. No state credentials, no
lock contention, no risk. It downloads provider schemas, which is all validate
needs.
validate and the policy scan run on every pull request. Both are fast, both
are local, and both are the steps that catch generated configuration before a
human spends attention on it.
plan is a separate, credentialed job — and it needs different trust rules.
A plan requires read access to the cloud account, so it must not run on an
untrusted pull request from a fork. The pattern that works is OIDC to a
read-only role, posting the plan as a PR comment, with the apply gated behind a
protected environment. That is covered in
GitHub Copilot for GitHub Actions and
built in the capstone.
Apply the saved plan file. The job that applies should consume the artifact the job that planned produced, not re-plan. Otherwise the approval was for a different set of changes.
Two things worth adding that suggestions omit: a concurrency group so two
pipelines cannot apply to the same environment simultaneously, and a check that
the plan is non-empty before requesting an approval — asking a human to approve
0 to add, 0 to change, 0 to destroy trains them to approve without reading.
Debugging
Terraform’s errors are better than most, and the two that consume the most time have specific remedies.
Cycle errors. Error: Cycle: aws_x.a, aws_y.b means two resources reference
each other. The message names the participants and not the attribute, which is
the hard part. terraform graph produces the dependency graph, and pasting the
cycle message plus the two resource blocks into chat is a good use of a model —
it is a small, well-defined reading problem.
Provider produced inconsistent result after apply. Almost always a provider
bug or an eventual-consistency issue rather than something in your configuration.
Worth recognising so you stop looking for a mistake you did not make.
Error: Invalid for_each argument when the map keys are not known at plan
time. This is the most common for_each failure and it happens when the keys
derive from another resource’s attributes. The fix is to key from something known
statically — a variable or a local — which is why the example keys subnets by
availability zone name rather than by a resource ID.
TF_LOG=DEBUG produces provider-level detail including the API calls, which is
occasionally the only way to understand a permissions failure. It also prints
request bodies, so treat the output as sensitive.
State, and why it deserves its own section
State is where Terraform keeps the mapping from configuration to real resources, and it is the part generated configuration most often mishandles.
State contains secrets in plain text. Every attribute of every resource,
including database passwords, generated keys and anything a provider returns.
sensitive = true hides a value from CLI output; it does nothing to state. A
terraform.tfstate file in Git is a credential leak.
A remote backend is not optional beyond one person. Local state means one machine holds the only record, with no locking, so two concurrent applies corrupt it. S3 with a lock, or Terraform Cloud, or an equivalent.
terraform state rm is destructive to the mapping, not the resource. It
makes Terraform forget a resource exists. The resource keeps running and keeps
billing, and the next plan proposes creating a duplicate. Generated remediation
advice suggests this more often than it should.
Drift is invisible to Copilot. It reads your configuration; it cannot know a resource was changed in the console last week. A plan that contains a surprise is frequently drift rather than a bug in the change you just made.
Modules
Copilot writes reasonable module interfaces and has two consistent weaknesses.
Module sources. A registry reference like terraform-aws-modules/vpc/aws is
plausible-looking and may not be the module you want, or may not exist at the
version pinned. Check the source and pin version — an unpinned registry module
resolves to whatever is latest at init time, which means two engineers can
initialise the same configuration and get different infrastructure.
Over-modularisation. A module wrapping a single resource with a passthrough variable for each attribute adds indirection and no abstraction. If the module has one caller and no logic, it is a resource with extra files.
For a module you did not write, the useful prompt is not “explain this” but something narrower:
Read this Terraform module and tell me, as a list:
- Every resource it creates.
- Every variable that has no default, so I know what I must supply.
- Anything that is publicly reachable when the defaults are used.
- Anything that would be replaced rather than updated if I changed a variable.
Quote the line for each. Do not suggest changes.
Item four is the one that repays the effort, and it is the question nobody thinks
to ask until the plan says 1 to destroy.
Terraform-specific risks
Hard-coded credentials. The headline. Checkov catches it; do not rely on that alone.
count where for_each belongs. Positional addressing and surprise
recreations.
Unpinned providers and modules. version = "~> 6.0" on providers,
an exact version on registry modules, and commit .terraform.lock.hcl.
Destructive replacements. Read the plan for forces replacement before
anything else.
Secrets in outputs. An output is recorded in state and readable by anything that consumes the module’s outputs. Nothing sensitive belongs there.
Missing prevent_destroy. A lifecycle { prevent_destroy = true } on a
database or a bucket turns an accidental destroy into a plan error. It is cheap
and no suggestion adds it.
Provider version drift. A major provider release can rename or restructure resources; a configuration that validated last year may not now.
Destructive commands
The third is the one worth being strict about. -auto-approve in CI is
defensible behind an approval gate on a plan a human already read. In a terminal
it removes the only step that catches a destroy.
Review workflow
- terraform fmt -recursiveRemoves style from the diff so the real change is visible.
- terraform init -backend=false && terraform validateType-checks against the provider schema with no credentials and no state.
- Run a policy scanCheckov, Trivy or TFLint. This is the step that found 18 issues validate reported as fine.
- Grep for credentials and wildcardsHuman judgementaccess_key, secret_key, password, token, Action = "*", Resource = "*".
- Check count versus for_eachHuman judgementAnything addressed positionally will renumber when the list changes.
- Read the plan — destroys and replacements firstHuman judgementThe summary line, then every `forces replacement` annotation.
- Apply a saved plan, not a re-planterraform apply tfplan applies exactly what was reviewed.
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Pin
required_versionand every provider; commit the lock file. - Prefer
for_each; usecountonly for a genuine zero-or-one conditional. - Derive values with functions rather than hard-coding them.
- Give every variable a description, a type, and a
validationblock where the valid set is knowable. - Put
lifecycle { prevent_destroy = true }on anything holding data. - Record scanner disagreements inline, inside the block, with a reason.
- Ask for the
fmt,validateand scan output alongside the configuration.
Common mistakes
- Treating
terraform validateas a security check. - Accepting
counton a list that will change. - Running
applywithout reading the plan, or applying a re-plan rather than a saved one. - Committing state, or leaving it local past the first collaborator.
- Silencing a scanner globally rather than suppressing one finding with a reason.
Where to go next
GitHub Copilot for OpenTofu covers what actually differs in the fork — the registry, the licence, and state encryption with no Terraform equivalent. GitHub Copilot for AWS covers the IAM and S3 controls this configuration touches, and the capstone runs this validation sequence on every pull request.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.