How to Deploy a Static Astro Site to S3 + CloudFront with Keyless OIDC
demonstrates: Reproducible, keyless static-site delivery a competent reader can follow end to end.
This guide takes you from an Astro repo to a live, CDN-delivered site on your own domain, with GitHub Actions deploying through a short-lived OIDC role and no AWS keys stored anywhere. It assumes you are comfortable with Git, the AWS CLI, and Terraform.
Before you start
- An Astro site that builds cleanly with
npm run buildintodist/. - An AWS account, plus the AWS CLI authenticated locally for the first Terraform run.
- Terraform installed (v1.6 or later).
- A domain you can manage in Route 53, or an existing Route 53 hosted zone.
- A GitHub repository holding the site, with a working
git pushto it. That means either an SSH key registered with GitHub, or the HTTPS credential helper, whichgh auth setup-gitconfigures for you. Step 6 is a push, and it is a frustrating place to discover you cannot make one.
1. Build the site locally
Confirm the site builds before you wire up any infrastructure.
npm ci
npm run build
You should see a dist/ directory containing index.html. If that is missing, stop and fix the build first. Astro provides documentation on how to set up your first build here.
2. Provision the infrastructure with Terraform
In an infra/ directory, declare these resources. Keep the S3 bucket private and let CloudFront reach it through an Origin Access Control, so the bucket is never world-readable.
Every file below is in the example repo, sierrajozajac/astro-s3-example. Copy the whole infra/ directory into your project, or clone it and read it first.
| Resource | File |
|---|---|
| A private S3 bucket (no public access, no website hosting) | infra/s3.tf |
| An Origin Access Control, attached to the CloudFront distribution | infra/cloudfront.tf |
| A CloudFront distribution with the bucket as origin and HTTPS enforced | infra/cloudfront.tf |
An ACM certificate in us-east-1 for your domain (CloudFront requires this region) | infra/acm.tf |
Route 53 A/AAAA alias records for the apex and www, pointing at CloudFront | infra/route53.tf |
| A GitHub OIDC identity provider and a deploy IAM role | infra/github-oidc.tf |
| The values you must change, and the outputs you will need | infra/terraform.tfvars.example, infra/outputs.tf |
You do not edit the .tf files. Every value that is yours lives in one place:
cd infra
cp terraform.tfvars.example terraform.tfvars
domain_name = "example.com" # your apex domain
bucket_name = "example-com-site-origin" # globally unique across all of AWS
github_repo = "your-username/your-site-repo" # the only repo that may deploy
region = "us-west-2" # bucket and IAM; ACM is always us-east-1
deploy_branch = "main" # the only branch that may deploy
create_oidc_provider = true # set false if your account already has one (see below)
The copied file has a few more optional settings (hosted_zone_name for serving a subdomain, role_name if you run this twice in one account); leave them at their defaults for a standard apex-domain deploy.
Two prerequisites the code does not create or reconcile for you:
- A Route 53 public hosted zone for your domain must already exist. Terraform reads that zone, it does not make it.
- AWS allows exactly one GitHub OIDC identity provider per account. If yours already has one, from a previous Actions-to-AWS setup, leave
create_oidc_provider = trueandterraform applyfails withEntityAlreadyExists. Check withaws iam list-open-id-connect-providers, and iftoken.actions.githubusercontent.comis already there, setcreate_oidc_provider = false. The existing provider is then looked up and reused. Do notterraform importa provider you share with other roles: a laterterraform destroywould delete it out from under them.
Then apply it:
terraform init
terraform plan
terraform apply
The first apply takes 5 to 15 minutes, nearly all of it CloudFront propagating.
Note the outputs. You will need the bucket name, the CloudFront distribution ID, and the deploy role ARN in step 5.
3. Confirm the deploy role is scoped
The role can already do only what a deploy needs, and can already be assumed only by your repository. Both live in infra/github-oidc.tf, and both were applied in step 2. Open that file and verify each of these:
- Confirm the permissions are minimal (
aws_iam_role_policy.deploy): it grants onlys3:PutObject,s3:DeleteObject, ands3:ListBucketon the site bucket, pluscloudfront:CreateInvalidationon the distribution, with no wildcard resources. - Confirm the trust policy pins the repo (
data.aws_iam_policy_document.github_assume_role): thesubcondition ties the role to one repo on one branch, built from yourgithub_repoanddeploy_branchvalues, for examplerepo:<owner>/<repo>:ref:refs/heads/main. A fork, a pull request, or any other repo is refused.
If you changed github_repo or deploy_branch after your first apply, re-run terraform apply so the trust policy catches up. Otherwise the role is already correct and this step is a read, not an edit.
4. Add the security gate
In the repo that holds your Astro site, create .github/workflows/deploy.yml. It holds two jobs: a scan job, and a deploy job that depends on it, so nothing ships if the scan flags a problem.
The finished workflow, both jobs, is in the example repo at workflows/deploy.yml. Copy it to .github/workflows/deploy.yml in your site repo now. This step walks the scan job; step 5 walks the deploy job in the same file. It sits outside .github/ in the example repo on purpose, because that repo is infrastructure only and has no site to build.
The scan job:
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: pip install semgrep
- run: semgrep --config auto --error .
Pin every action to a commit SHA, not a moving tag like @v4. A tag can be repointed at new code by whoever controls it; a SHA cannot. This is not optional here: semgrep --config auto flags a mutable tag as a blocking finding, so @v4 would fail the very gate you just built. The # v4 comment is only there to tell a human which version the SHA is.
5. Add the deploy job
The second job in the file you copied is the deploy job. It assumes the OIDC role at runtime, so there are no long-lived keys in repo secrets.
deploy:
needs: sast # the gate: without this, a failing scan still ships
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20 # match the version you build with locally
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-west-2 # your bucket's region, not us-east-1
- run: aws s3 sync ./dist s3://${{ vars.S3_BUCKET }} --delete
- run: aws cloudfront create-invalidation --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
Two values here must agree with your terraform.tfvars or the deploy fails:
aws-regionmust matchregionin your tfvars. It is the region your bucket is in, notus-east-1unless you put your bucket there; only the ACM certificate lives inus-east-1.- The workflow’s
branches:must matchdeploy_branch. The IAM trust policy accepts a token from that branch and no other. A mismatch fails at the credentials step withNot authorized to perform sts:AssumeRoleWithWebIdentity.
Now set the three values Terraform printed. In your site repo on GitHub, go to Settings > Secrets and variables > Actions > Variables and add them as repository variables, not secrets. None of them are sensitive.
| Repository variable | Where it comes from |
|---|---|
AWS_DEPLOY_ROLE_ARN | terraform output deploy_role_arn |
S3_BUCKET | terraform output bucket_name |
CLOUDFRONT_DISTRIBUTION_ID | terraform output distribution_id |
These names match the example repo’s workflow and README, so you can move between all three without renaming anything.
6. Run your first deploy
git push origin main
Watch the run on GitHub, at https://github.com/<owner>/<repo>/actions, or click the Actions tab at the top of your repo. The sast job runs first, then deploy builds, syncs to S3, and invalidates the cache.
When both are green, load your site over HTTPS. The URL is the domain_name you set in terraform.tfvars, so https://example.com and https://www.example.com both serve it.
DNS and certificates do not always propagate as fast as the deploy finishes. If your domain does not resolve yet, CloudFront’s own hostname works immediately and proves the deploy itself succeeded:
terraform output distribution_domain_name
# d1234abcdefgh.cloudfront.net
7. Confirm it is keyless and private
No stored keys. In your site repo, open Settings > Secrets and variables > Actions. The Secrets tab should be empty of AWS credentials: no AWS_SECRET_ACCESS_KEY, no AWS_ACCESS_KEY_ID. Only the three non-sensitive repository variables from step 5 should be there.
The bucket refuses you. Ask S3 directly for a file the site is serving, bypassing CloudFront. Use your own bucket name and region:
curl -I https://example-com-site-origin.s3.us-west-2.amazonaws.com/index.html
HTTP/1.1 403 Forbidden
403 is the answer you want. It means the public access block is holding and the object is not readable from the open internet. If you get a 200, the bucket is serving to the world and something is wrong.
The CDN serves you. Ask for the same object through your domain:
curl -I https://example.com/index.html
HTTP/2 200
x-cache: Hit from cloudfront
Same file, reachable only through CloudFront. The x-cache header confirms the request was answered at an edge location rather than falling through to the origin.