Continuing the series — your last article ended with "next up: automating deployment with GitHub Actions." I'll write that one.
How I automated deploying my static site to S3 and CloudFront using GitHub Actions — scoped IAM permissions, OIDC instead of long-lived keys, and cache invalidation, including what broke along the way.
Automating My S3 + CloudFront Deployment with GitHub Actions
Over the last two articles, I hosted a static site on S3 and put CloudFront in front of it for HTTPS and a custom domain. That setup works, but every update meant manually uploading files through the console and remembering to invalidate the CloudFront cache. This article covers how I automated that with GitHub Actions, so a
git push is now the only step required to deploy.The Problem with Manual Deployment
Uploading through the console is fine for a one-time setup, but it doesn't scale even for a small personal site:
- It's easy to forget a file, or upload to the wrong path
- Cache invalidation is a separate manual step that's easy to skip
- There's no record of what was deployed when
- It doesn't scale to "make a change, see it live in a minute" — which is the whole point of a fast feedback loop
A basic CI/CD pipeline fixes all of this, and it's a good next step because it introduces a genuinely different skill: giving an external system (GitHub) permission to act on your AWS account safely.
The Pieces Involved
- GitHub repository — where the site's source lives
- IAM user or role with scoped permissions — so GitHub Actions can talk to AWS
- GitHub Actions workflow — automates sync-to-S3 and cache invalidation
- GitHub Secrets — stores AWS credentials securely, outside the repo
Step 1: Create a Narrowly Scoped IAM Identity
This is the part worth slowing down on. It's tempting to reuse your own admin credentials to get something working quickly — don't. GitHub Actions only needs to do two things: upload files to one specific bucket, and invalidate one specific CloudFront distribution.
I created an IAM user (you could also use an IAM role with GitHub's OIDC provider, which avoids long-lived credentials entirely — more on that below) with a policy scoped to exactly those two actions:
json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::YOUR-BUCKET-NAME",
"arn:aws:s3:::YOUR-BUCKET-NAME/*"
]
},
{
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation"],
"Resource": "arn:aws:cloudfront::YOUR-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"
}
]
}Notice what's not in this policy: no permission to create or delete buckets, no IAM permissions, no access to any other service. If these credentials ever leaked, the blast radius is limited to "someone can overwrite my static site files" — annoying, but nowhere near as bad as broader access would be.
Step 2: A Better Option — OIDC Instead of Access Keys
Long-lived IAM access keys stored as GitHub secrets work, but they're not the best practice anymore. AWS supports OpenID Connect (OIDC) federation with GitHub Actions, which lets GitHub request short-lived, temporary credentials for each workflow run — no long-lived secret sitting in your repo settings at all.
This involves:
- Adding GitHub as an OIDC identity provider in IAM
- Creating an IAM role (not a user) with a trust policy scoped to your specific repository
- Referencing that role's ARN in the workflow instead of access keys
I switched to this approach after getting the access-key version working first — it's a bit more setup, but it's a meaningfully better security posture, and it's worth doing once you understand the basic flow.
Step 3: Store Credentials as GitHub Secrets
If using access keys, add them under your repository's Settings → Secrets and variables → Actions:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYCLOUDFRONT_DISTRIBUTION_IDS3_BUCKET_NAME
If using OIDC, you only need the role ARN as a secret (or even as a plain repo variable, since it's not sensitive on its own).
Step 4: Write the Workflow
Here's the GitHub Actions workflow I ended up with, triggered on every push to
main:yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: Deploy to S3 and CloudFront
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR-ACCOUNT-ID:role/github-actions-deploy
aws-region: us-east-1
- name: Sync files to S3
run: aws s3 sync ./site s3://${{ secrets.S3_BUCKET_NAME }} --delete
- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"A few details worth explaining:
--deleteon the sync command removes files from S3 that no longer exist in the source folder. Without it, deleted local files would linger in the bucket forever.permissions: id-token: writeis required for OIDC — it's what lets the workflow request a token to exchange for temporary AWS credentials.- Invalidating
/*is simple but not the most efficient approach for larger sites; a more advanced version would invalidate only changed paths.
Step 5: Test It
Push a small change — edit a heading, commit, push to
main — and watch the Actions tab in GitHub. If it's configured correctly, you'll see the workflow run, sync the files, invalidate the cache, and your live site updates within a minute or two.What Went Wrong the First Time
- My first IAM policy was missing
s3:ListBucket, whichaws s3 syncneeds to compare what's already in the bucket against the local folder. Without it, the sync failed with an access-denied error that didn't obviously point to the missing permission. - I initially set the workflow's AWS region to my S3 bucket's region, but CloudFront invalidation calls don't care about that — the CLI figures out the right endpoint from the distribution ID. Not actually a bug, just a moment of unnecessary confusion while debugging something else.
- The first OIDC trust policy I wrote was scoped too loosely — to any workflow in my GitHub account rather than the specific repository. Worth double-checking the trust policy's
subcondition matches your repo exactly.
What This Taught Me
CI/CD is really just "permissions plus automation." The GitHub Actions YAML itself is almost trivial. The actual engineering work is scoping the IAM permissions correctly so the automation can do its job without being able to do anything else.
OIDC is worth the extra setup. Storing long-lived AWS keys in any third-party system is a standing liability. Temporary, per-run credentials shrink that risk considerably, and once the trust relationship is set up, there's no ongoing secret rotation to think about.
Least privilege gets more concrete with practice. Writing IAM policies for a real automated system — rather than just clicking through the console myself — made "grant the minimum permissions needed" feel like a practical habit rather than an abstract security principle.
What's Next
With deployment automated, the next thing I want to explore is adding a staging environment — a separate S3 bucket and CloudFront distribution for a
staging branch, so I can preview changes before they go live on the main site.If you've built something similar, I'd be curious how you handled staging vs. production for a project this size — feels like it could easily be over-engineered for a personal site, and I'd rather learn from someone who's found the right balance.
# github# aws-cloudformation# aws-cloud-development-kit# aws-cloud-financial-management# advanced-networking
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article