../writing

How-To guide

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.

2026-07-18 · how-toawsastrocloudfrontoidcterraformgithub-actions

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 build into dist/.
  • 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 push to it. That means either an SSH key registered with GitHub, or the HTTPS credential helper, which gh auth setup-git configures 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.

ResourceFile
A private S3 bucket (no public access, no website hosting)infra/s3.tf
An Origin Access Control, attached to the CloudFront distributioninfra/cloudfront.tf
A CloudFront distribution with the bucket as origin and HTTPS enforcedinfra/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 CloudFrontinfra/route53.tf
A GitHub OIDC identity provider and a deploy IAM roleinfra/github-oidc.tf
The values you must change, and the outputs you will needinfra/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 = true and terraform apply fails with EntityAlreadyExists. Check with aws iam list-open-id-connect-providers, and if token.actions.githubusercontent.com is already there, set create_oidc_provider = false. The existing provider is then looked up and reused. Do not terraform import a provider you share with other roles: a later terraform destroy would 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 only s3:PutObject, s3:DeleteObject, and s3:ListBucket on the site bucket, plus cloudfront:CreateInvalidation on the distribution, with no wildcard resources.
  • Confirm the trust policy pins the repo (data.aws_iam_policy_document.github_assume_role): the sub condition ties the role to one repo on one branch, built from your github_repo and deploy_branch values, for example repo:<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-region must match region in your tfvars. It is the region your bucket is in, not us-east-1 unless you put your bucket there; only the ACM certificate lives in us-east-1.
  • The workflow’s branches: must match deploy_branch. The IAM trust policy accepts a token from that branch and no other. A mismatch fails at the credentials step with Not 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 variableWhere it comes from
AWS_DEPLOY_ROLE_ARNterraform output deploy_role_arn
S3_BUCKETterraform output bucket_name
CLOUDFRONT_DISTRIBUTION_IDterraform 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.