Deploying a Static Site to AWS using Terraform and GitHub Actions

September 29, 2025

The AWS, Terraform, and GitHub actions logos on a green to orange gradient arc.

How to deploy a static site using repeatable, version-controlled infrastructure as code (IaC), stay within Amazon Web Services’ free tier, and leverage global caching with Amazon CloudFront.


Why Terraform?

I wanted my site infrastructure to live in version control, right next to my code.
Terraform is great for that. It lets you declare what you want your cloud to look like, then handles the how behind the scenes.

A few reasons I like it:

  • It’s declarative, you describe the end state. Years ago, had you asked me how to automate this process, my instinct would have been to write a script, i.e. an imperative list of steps to arrive at the desired outcome. But having worked in environments that are hanging by the thread of poorly maintained and documented bash scripts, I see the value of industry standard, structured, version controlled IaC tooling.
  • It tracks state, so you can see what’s changing before applying it.
  • It plays nicely with automation, especially when paired with GitHub Actions and OpenID Connect (OIDC).

Like many tools, Terraform has a bit of an “it’s hard until it’s easy” learning curve. Once you get your first deployment running, everything starts to click.


Connecting GitHub Actions to AWS Securely

To deploy automatically when I push to my repository, GitHub needs a way to act on my AWS account without permanent credentials.
That’s where OIDC comes in. Instead of storing access keys, GitHub authenticates using short-lived tokens.

Here’s the Terraform setup that makes that work:

Allow GitHub to be trusted as an identity provider

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
  tags = { Name = "GitHub-Actions-OIDC-Provider" }
}

Create a role GitHub can assume

resource "aws_iam_role" "github_actions" {
  name = "github-actions-deploy"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn },
      Action = "sts:AssumeRoleWithWebIdentity",
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:sub" = "repo:<your-username>/<your-repo>:ref:refs/heads/main",
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
      }
    }]
  })
}

Attach permissions for S3 and CloudFront

data "aws_iam_policy_document" "github_actions_policy" {
  statement {
    actions = ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject", "s3:ListBucket"]
    resources = [
      "arn:aws:s3:::${local.site_bucket}",
      "arn:aws:s3:::${local.site_bucket}/*"
    ]
  }

  statement {
    sid = "CloudFrontInvalidationAccess"
    actions = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
    resources = [module.cdn.cloudfront_distribution_arn]
  }
}

Automating the Deployment

With the IAM role created, the GitHub Actions workflow just needs to assume it:

name: Deploy Astro site to AWS S3

on:
  push:
    branches: [ main ]
  workflow_dispatch:

env:
  AWS_REGION: us-east-1

permissions:
  id-token: write   # needed for GitHub → AWS OIDC
  contents: read    # needed to checkout the repo

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # Builds the Astro site into ./dist
      - name: Build Astro site
        uses: withastro/action@v3

      # Pass build output to the deploy job
      - name: Upload build output
        uses: actions/upload-artifact@v4
        with:
          name: site-dist
          path: dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download site artifact
        uses: actions/download-artifact@v4
        with:
          name: site-dist
          path: dist

      # Assumes the role that Terraform created
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_GHA_ROLE_ARN }}
          role-session-name: GitHub_to_AWS_via_FederatedOIDC
          aws-region: ${{ env.AWS_REGION }}

      # Sync static files to S3
      - name: Sync to S3
        run: aws s3 sync --delete ./dist/ s3://${{ secrets.AWS_S3_BUCKET_NAME }}

      # Bust the CDN cache so the new site shows up
      - name: Invalidate CloudFront
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"