GitHub Copilot for AWS

GitHub Copilot for DevOps & InfrastructureAcademy lesson 47Cluster 4 · Lesson 9 of 13Intermediate → Advanced15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for AWSGitHub Copilot for DevOps & Infrastructure9Intermediate → Advanced/github-copilot/devops/aws/

Everything in this lesson is a repository artifact. The AWS CLI is deliberately not installed in the environment these examples were written in, no credentials exist, and nothing here has ever contacted AWS. That is not a limitation being apologised for — it is the working model this lesson argues for. The overwhelming majority of AWS work Copilot can usefully help with is text: policy documents, infrastructure as code, and CLI invocations you read before running.

The one thing that genuinely distinguishes AWS from the other clouds for AI-assisted work is IAM. It is a large, precise, extremely well-documented permission language, which makes it a good fit for a model — and its failure mode is that the shortest correct-looking policy is "Action": "*" on "Resource": "*", which is administrator access.

Where Copilot helps with AWS

IAM policy drafting, with the caveat that runs through this whole lesson. The action names are the part nobody remembers — s3:ListBucket is on the bucket while s3:GetObject is on the objects, and getting that wrong produces a policy that looks right and denies everything.

Terraform and CloudFormation for AWS resources. Covered in the Terraform lesson; the AWS provider’s schema is enormous and remembering it is not a good use of anyone’s memory.

Explaining an existing policy or architecture. “What can this role actually do?” against a forty-line policy document is a reading task with a checkable answer.

CLI invocations — with the strong preference that you ask for the command and the explanation, and run it yourself.

Reading errors. AccessDenied in AWS names the action, the principal and frequently the resource. It is precise input, and turning it into “you need s3:PutObjectAcl on this ARN” is quick and reliable.

Where it needs correcting most: scope. Not syntax — the syntax is usually perfect.

The IAM problem, measured

A plausible draft policy — the shape you get from “give me an IAM policy for a CI role that deploys to S3”:

{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "AllowEverything", "Effect": "Allow",
      "Action": "*", "Resource": "*" },
    { "Sid": "S3FullAccess", "Effect": "Allow",
      "Action": "s3:*", "Resource": "*" },
    { "Sid": "AnyoneCanAssume", "Effect": "Allow",
      "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": { "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" } } }
  ]
}

It is valid JSON, valid IAM, and would apply without complaint. The local checker in the example below reported:

FINDING  admin-policy.json    grants a wildcard Action
FINDING  admin-policy.json    OIDC trust policy with no subject condition
1 policy document checked, 2 finding(s)
(exit 1)

The correct trust policy adds:

"StringLike": {
  "token.actions.githubusercontent.com:sub":
    "repo:example-org/copilot-demo:ref:refs/heads/main"
}

Note StringLike with an explicit ref rather than a trailing wildcard. A sub of repo:example-org/copilot-demo:* permits any branch, any tag, and any pull request in that repository — which means anyone who can open a PR can assume the role.

Practical project: policies you can check locally

Practical example

Least-privilege IAM, a local policy checker, and an S3 bucket done properly

Artifacts that can be validated with no credentials, plus a checker that catches the two IAM defaults that matter.

Status
Tested implementation
Runtime
jq 1.7, ShellCheck 0.11.0, Terraform v1.15.9 with hashicorp/aws v6.61.0, Checkov 3.3.13
Command
jq empty policies/*.json && ./scripts/check-policies.sh policies && terraform validate && checkov -d terraform --framework terraform
Result
All three policies valid JSON. Checker: 3 checked, 0 findings, exit 0 — and 2 findings, exit 1, against the draft. terraform validate: Success! The configuration is valid. Checkov: 43 passed, 0 failed, 7 skipped, every skip with a stated reason.
Run on
August 21, 2026

Files

examples/cluster-4/aws

copilot-aws-demo/ ├── policies/ │ ├── read-only-inventory.json region-scoped describe-only │ ├── artifact-bucket-writer.json one prefix, encryption required │ └── ci-oidc-trust.json OIDC trust with a sub condition ├── draft/admin-policy.json the over-permissive first suggestion ├── scripts/check-policies.sh static assertions, no credentials ├── terraform/main.tf an artifact bucket └── .github/ └── instructions/ └── aws.instructions.md

Scoping with conditions

{
  "Sid": "WriteBuildArtifactsOnly",
  "Effect": "Allow",
  "Action": ["s3:PutObject", "s3:GetObject"],
  "Resource": "arn:aws:s3:::copilot-demo-artifacts/builds/*",
  "Condition": {
    "StringEquals": {
      "s3:x-amz-server-side-encryption": "aws:kms"
    }
  }
}

Three narrowings in one statement. The action list is explicit rather than s3:*. The resource is one prefix inside one bucket rather than *. And the condition requires that objects are written encrypted — a request without the encryption header is denied, which enforces encryption through IAM rather than hoping the client sets it.

That third one is the kind of thing worth asking for by name. Generated policies essentially never include a condition unless the prompt says so.

The checker

Policies are JSON, so they can be asserted on locally with no AWS account at all:

# A wildcard Action grants every operation the service offers, now and in
# every future release. There is no legitimate use in an identity policy.
if jq -e '[.Statement[] | select(.Effect == "Allow")
           | (.Action | if type == "array" then .[] else . end)]
          | any(. == "*" or endswith(":*"))' -- "$file" > /dev/null; then
  fail "$name" "grants a wildcard Action"
fi

The full script checks four things: wildcard actions, wildcard resources on mutating actions, NotAction, and OIDC trust policies missing a subject condition. It runs in a pull request, needs nothing but jq, and it caught both problems in the draft.

This is worth generalising. Anything expressed as JSON can be gated locally. A twenty-line jq script that encodes your organisation’s IAM rules will catch more generated-policy problems than a review will, because it never gets tired.

The S3 bucket

The Terraform in this example sets what generated S3 resources omit: all four public access block settings, BucketOwnerEnforced ownership so ACLs stop mattering, versioning, KMS encryption, a lifecycle rule aborting incomplete multipart uploads, access logging to a separate bucket, and a bucket policy denying any request where aws:SecureTransport is false.

That last one is worth naming. Without it, a bucket policy that allows access allows it over plain HTTP as well as HTTPS.

Observed output

$ ./scripts/check-policies.sh policies
checked  artifact-bucket-writer.json
checked  ci-oidc-trust.json
checked  read-only-inventory.json
3 policy document(s) checked, 0 finding(s)
(exit 0)

$ ./scripts/check-policies.sh draft
FINDING  admin-policy.json    grants a wildcard Action
FINDING  admin-policy.json    OIDC trust policy with no subject condition
1 policy document(s) checked, 2 finding(s)
(exit 1)

$ checkov -d terraform --framework terraform
passed=43 failed=0 skipped=7

Validation

Credentials, and the thing never to generate

The provider and the CLI both read credentials from the environment. That is the entire configuration:

export AWS_PROFILE="example"
export AWS_REGION="eu-west-1"

A generated provider "aws" block containing access_key and secret_key is the single most serious artifact this lesson deals with, and Checkov’s CKV_AWS_41 catches it — one of the few cases where a scanner reliably finds a secret.

For CI, use OIDC. GitHub Actions can exchange its workload identity token for temporary AWS credentials, which removes the stored key entirely. There is no secret to rotate, no secret to leak, and the credentials expire in an hour. The trust policy is the control, and the sub condition is the part that matters.

Copilot promptAsking for a trust policy safelyCopilot Chat

Write an IAM trust policy allowing GitHub Actions to assume a role via OIDC.

It must be assumable only from the repository example-org/copilot-demo, only on refs/heads/main, and only for the sts.amazonaws.com audience.

Explain what each condition constrains, and tell me explicitly what would be possible if the sub condition were omitted.

The last clause is the one that teaches. A model asked to explain the consequence of omitting the condition will describe the compromise accurately, and having read that once you will not accept a trust policy without it again.

The AWS CLI, and how to use a model with it

The pattern from the pillar, in its AWS form:

Copilot promptAnalysis before executionCopilot Chat

I need to change the lifecycle policy on a production S3 bucket.

Do not give me the command yet. Tell me:

  • exactly what it will change
  • the IAM permissions required
  • whether the action is destructive, and what it would delete
  • the cost implication
  • a read-only command to record the current state first
  • how to roll it back

Then give me the command, with —dry-run if the operation supports it.

Three AWS-specific hazards this shape addresses.

The --profile and region are ambient. A command with neither uses whatever AWS_PROFILE and AWS_REGION happen to be, which may be production. aws sts get-caller-identity before anything that writes is the equivalent of kubectl config current-context.

Many delete operations do not prompt. aws s3 rb --force empties and removes a bucket with no confirmation.

Some CLI defaults are surprising. aws s3 sync with --delete removes destination objects not present in the source, which is correct behaviour and catastrophic if source and destination were transposed.

The services, and what to check in each

This is not an AWS tutorial and there are hundreds of services. These five come up constantly in generated infrastructure, and each has a default worth knowing.

VPC and networking. Generated VPCs default to permissive: subnets with map_public_ip_on_launch, a default security group that allows all traffic between its members, and no flow logs. The Terraform lesson example addresses all three. The cost trap here is NAT gateways — a generated three-AZ VPC with a NAT gateway per zone is three hourly charges plus per-gigabyte processing, which is frequently the largest line on a small account’s bill.

EC2. Check the AMI. A hard-coded AMI ID is region-specific and goes stale; a data source filtered on owner and name pattern is the maintainable form, and the owner filter is the security-relevant part — without it, a name pattern can match somebody else’s public image. Also check root_block_device encryption, which is off unless asked for, and IMDSv2 enforcement (http_tokens = "required"), which mitigates a whole class of credential theft via SSRF and is not the default in older provider versions.

S3. Covered above. The one addition worth making: a bucket policy and an IAM policy can both grant access, and reviewing one without the other gives an incomplete picture.

Lambda. Generated functions frequently get an over-broad execution role because the minimal one is tedious to write. They also default to 128MB of memory and a three-second timeout, which for anything touching a database is a timeout waiting to happen. And environment variables on a Lambda are visible to anyone with lambda:GetFunction — a secret belongs in Secrets Manager or Parameter Store, retrieved at runtime.

ECS and EKS. The task role and the execution role are different things and generated task definitions routinely conflate them: the execution role pulls the image and writes logs, the task role is what your code uses. Giving the execution role your application’s permissions is a common and quiet over-grant.

CloudWatch. Log groups created implicitly by a service have no retention policy, which means they retain forever and bill forever. An explicit retention_in_days is one line and no generated resource includes it.

CloudFormation, CDK, and choosing the tool

Copilot writes all three of AWS’s IaC options, and the choice affects how reviewable the output is.

CloudFormation YAML or JSON is verbose and completely explicit, which makes it the easiest to review and the most tedious to write. Its distinctive hazard is the DeletionPolicy attribute: the default is Delete, so removing a resource from a template deletes it. Retain on stateful resources is the protection, and generated templates omit it. cfn-lint is the local validator, and aws cloudformation deploy --no-execute-changeset produces the equivalent of a plan.

CDK generates CloudFormation from TypeScript or Python, which means everything in Cluster 3 about those languages applies — plus one thing specific to CDK: the L2 constructs apply sensible defaults, so a generated new Bucket(this, 'Data') is encrypted and private in a way the raw CloudFormation equivalent would not be. That is genuinely helpful and it makes the review different: you are checking which construct level was used and what the defaults are, not reading every property.

Terraform is covered in its own lesson and is the most common in practice.

The practical advice is to name the tool in your instructions file. A repository that gets CDK suggestions in a Terraform codebase is a repository where nobody said which one it uses, and the two are not interchangeable.

Cost

Cost is the AWS-specific risk that no linter catches and no model can answer accurately, and generated infrastructure is systematically expensive because examples optimise for working rather than for being cheap.

The resources that most often appear unrequested and bill continuously: NAT gateways, Application and Network Load Balancers, provisioned-IOPS EBS volumes, Multi-AZ RDS in a development environment, always-on ECS services with a minimum task count, and CloudWatch log groups with no retention.

A model cannot tell you what these cost in your account — pricing varies by region, and your account may have commitments or savings plans. What it can do, usefully, is enumerate which resources in a change have recurring costs, which is a different and answerable question.

Copilot promptA cost question with an answerable shapeCopilot Chat

Here is a Terraform plan.

List every resource it creates that bills continuously rather than per-request, and for each one say what drives the charge — per hour, per gigabyte processed, per provisioned unit.

Do not estimate a figure. I will price it against our account.

“Do not estimate a figure” is deliberate. Ask for a number and you will get a confident one, drawn from list prices in an unspecified region at an unspecified date. Ask for the shape of the charge and the answer is checkable and useful.

Tools that do this properly exist — Infracost is the mainstream one — and running one in CI alongside the policy scan is the systematic version of the same idea.

AWS-specific risks

Wildcard IAM. The headline. "Action": "*", "Action": "s3:*", "Resource": "*" on a mutating action, and NotAction.

OIDC trust policies without a subject condition. Covered above.

Public S3. A bucket without all four public access block settings, or with a policy granting Principal: "*".

Open security groups. 0.0.0.0/0 on port 22 or 3389 is the classic; on any port it is worth a question.

Region assumptions. us-east-1 appears in generated AWS code far more than in real infrastructure. It is also the only region where certain global services live, which is why it is the default in examples — and using it by accident scatters resources across regions.

Long-lived access keys. An IAM user with a key in a repository secret, rather than a role assumed via OIDC.

Resources without deletion protection. RDS deletion_protection, S3 versioning, Terraform prevent_destroy. Cheap, and absent from generated resources.

Cost-bearing defaults. A NAT gateway per availability zone, a provisioned IOPS volume, an always-on load balancer. Each is a single line and a recurring bill.

Guardrails above the account

Everything above is a control you apply inside one account. AWS also has controls above the account, and they matter for AI-assisted work because they are the only mechanism that constrains a mistake nobody reviewed.

Service Control Policies at the organisation level set a ceiling on what any principal in an account can do, including the root user and including an administrator. An SCP denying s3:PutBucketPublicAccessBlock with a Delete effect means no generated Terraform, however over-permissive, can make a bucket public. This is a different kind of protection from a scanner: it does not depend on anyone running anything.

Permission boundaries cap what a role can grant. If your CI role can create IAM roles — which it needs to, if it provisions infrastructure — a boundary stops it creating one more powerful than itself. Privilege escalation via role creation is a well-known path and generated Terraform that creates roles rarely attaches a boundary.

A separate account per environment makes the ambient-context problem structural rather than procedural. You cannot accidentally apply a development change to production if the credentials for production are not in your shell.

None of these appears in generated configuration, because none of them is part of the resource you asked for. They are worth knowing about anyway, because they are the answer to “what stops this when the review fails” — and a review process that has no answer to that question is relying entirely on people being careful.

Destructive commands

The third one deserves the most caution, because --delete is frequently suggested as the way to make a sync “clean” and the argument order is easy to get backwards.

Review workflow

Accepting generated AWS artifacts
  1. Grep for credentialsHuman judgementaccess_key, secret_key, session token, or a hard-coded account ID that is not yours.
  2. Run the policy checkerjq assertions over policy JSON. Wildcards, NotAction, and OIDC subject conditions.
  3. terraform validate and a policy scanCheckov reads AWS Terraform well and catches the public-access and encryption classes.
  4. Read every trust policyHuman judgementWhich principal, under what conditions, and what is possible if a condition is missing.
  5. Check what is reachable from the internetHuman judgementPublic access blocks, security groups, publicly_accessible.
  6. Check the region and the profileHuman judgementaws sts get-caller-identity before anything that writes.
  7. Read the plan for destroys and replacementsHuman judgement

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

  • Never generate credentials into a file; use AWS_PROFILE locally and OIDC in CI.
  • Require a sub condition on every OIDC trust policy, naming the repository and the ref.
  • Ask for conditions, not just actions and resources — region, prefix, encryption header.
  • Encode your IAM rules as a jq script and run it on every pull request.
  • All four public access block settings, and BucketOwnerEnforced ownership.
  • Set prevent_destroy and deletion_protection on anything holding data.
  • Ask for the cost implication of any new resource, and treat the answer as a prompt to verify.

Common mistakes

  • Accepting "Action": "s3:*" because the alternative is a longer list.
  • Shipping an OIDC trust policy with only an aud condition.
  • Setting three of the four public access block settings.
  • Running a CLI command without checking which profile and region are active.
  • Assuming a scanner will catch a scoping problem — most check for public access and encryption, not for whether a role is broader than it needs to be.

Where to go next

GitHub Copilot for Terraform covers the tool most of this is written in. GitHub Copilot for GitHub Actions covers the CI side of the OIDC exchange, and GitHub Copilot for Microsoft Azure is the equivalent lesson for the other large cloud, where the ambient-context problem takes a different and slightly more dangerous form.

Sources

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

Primary sources