GitHub Copilot for Terraform

GitHub Copilot for DevOps & InfrastructureAcademy lesson 43Cluster 4 · Lesson 5 of 13Intermediate → Advanced15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for TerraformGitHub Copilot for DevOps & Infrastructure5Intermediate → Advanced/github-copilot/devops/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 failed

The 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”.

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 description

Three 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

examples/cluster-4/terraform

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

Copilot promptGenerate the VPCCopilot Chat, with versions.tf open

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=4

Validation

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 failed

The 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:

Copilot promptUnderstanding an inherited moduleCopilot Chat, with the module open

Read this Terraform module and tell me, as a list:

  1. Every resource it creates.
  2. Every variable that has no default, so I know what I must supply.
  3. Anything that is publicly reachable when the defaults are used.
  4. 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

Accepting generated Terraform
  1. terraform fmt -recursiveRemoves style from the diff so the real change is visible.
  2. terraform init -backend=false && terraform validateType-checks against the provider schema with no credentials and no state.
  3. Run a policy scanCheckov, Trivy or TFLint. This is the step that found 18 issues validate reported as fine.
  4. Grep for credentials and wildcardsHuman judgementaccess_key, secret_key, password, token, Action = "*", Resource = "*".
  5. Check count versus for_eachHuman judgementAnything addressed positionally will renumber when the list changes.
  6. Read the plan — destroys and replacements firstHuman judgementThe summary line, then every `forces replacement` annotation.
  7. 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_version and every provider; commit the lock file.
  • Prefer for_each; use count only 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 validation block 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, validate and scan output alongside the configuration.

Common mistakes

  • Treating terraform validate as a security check.
  • Accepting count on a list that will change.
  • Running apply without 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.

Primary sources