skies.dev

How to Deploy a Gatsby Site to AWS with GitHub Actions

4 min read

Build and Deploy a Gatsby Site to AWS with GitHub Actions

In the hosting post, we set up the AWS side of the stack: S3 as the origin, CloudFront as the public edge cache, and Terraform to create the infrastructure. This article finishes the loop by turning Gatsby builds into a deployment pipeline.

The goal is simple:

  • Every push should build the site.
  • Only pushes to the main branch should deploy.
  • Deployments should sync the generated public/ directory to S3.
  • CloudFront should be invalidated after a successful upload so users do not wait for stale content to expire.

Create an IAM Policy for GitHub Actions

GitHub Actions needs permission to write to the S3 bucket and create CloudFront invalidations. I keep that permission in a Terraform-managed policy so the deployment credentials stay narrow and auditable.

terraform/policies/deploy.json.tpl
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "StaticSiteDeployment",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket",
        "s3:DeleteObject",
        "cloudfront:CreateInvalidation"
      ],
      "Resource": ["${cf_arn}", "${s3_arn}", "${s3_arn}/*"]
    }
  ]
}

That policy gives the workflow only the capabilities it needs for deployment. The S3 permissions cover the bucket and its objects, and the CloudFront permission is limited to invalidation.

Wire the Policy into Terraform

The policy template is rendered in Terraform and attached to a dedicated IAM user.

terraform/iam.tf
data "template_file" "static_website_deployment_document" {
  template = file("policies/deployment.json.tpl")

  vars = {
    cf_arn = aws_cloudfront_distribution.s3_distribution.arn
    s3_arn = aws_s3_bucket.my_bucket.arn
  }
}

resource "aws_iam_policy" "static_website_deployment_policy" {
  name        = "static-website-deployment-policy"
  description = "Deploys Gatsby site to AWS S3 and CloudFront"
  policy      = data.template_file.static_website_deployment_document.rendered
}

resource "aws_iam_user" "static-website-deployment-user" {
  name = "static-website-deployment-user"
}

resource "aws_iam_user_policy_attachment" "attach_deployment_policy" {
  user       = aws_iam_user.static-website-deployment-user.name
  policy_arn = aws_iam_policy.static_website_deployment_policy.arn
}

resource "aws_iam_access_key" "deployment_key" {
  user = aws_iam_user.static-website-deployment-user.name
}

The access key is what GitHub Actions will use. I also keep the resulting Terraform state out of version control because the key material is sensitive.

.gitignore
*.tfstate*

Add GitHub Secrets

Before the workflow can deploy anything, GitHub needs the AWS credentials and deployment targets.

  • AWS_ACCESS_KEY_ID is the IAM access key ID.
  • AWS_SECRET_ACCESS_KEY is the matching secret.
  • AWS_BUCKET_NAME is the name of the S3 bucket that stores the site.
  • AWS_CLOUDFRONT_DISTRIBUTION_ID is the distribution that serves the site.
  • AWS_REGION should match the Terraform region, which is us-east-1 in this stack.

You can extract the access key values from the Terraform state after the IAM resources have been created.

Define the Workflow

The GitHub Actions workflow has two jobs in practice, even though they live in one file:

  1. Build the Gatsby site on every push or pull request.
  2. Deploy only when the event is a direct push to the main branch.
.github/workflows/main.yml
name: Deployment
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: yarn --frozen-lockfile

      - name: Build website
        run: yarn build

The build step is intentionally separate from deployment. That makes pull requests useful as a validation gate: they confirm the site still compiles without touching AWS.

Now add the deployment steps that only run for pushes.

.github/workflows/main.yml
name: Deployment
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: yarn --frozen-lockfile

      - name: Build website
        run: yarn build

      - name: Configure AWS credentials
        if: github.event_name == 'push'
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Deploy to S3
        if: github.event_name == 'push'
        run: aws s3 sync public s3://${{ secrets.AWS_BUCKET_NAME }} --delete

      - name: Invalidate CloudFront
        if: github.event_name == 'push'
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

Why This Works

The workflow is doing three separate jobs, and keeping them explicit makes it easier to reason about failures:

  • The build catches Gatsby errors before any deployment begins.
  • The if: github.event_name == 'push' checks prevent pull requests from publishing to production.
  • aws s3 sync updates only the files that changed, and --delete removes stale assets from previous builds.
  • The CloudFront invalidation forces the edge cache to pick up the new version quickly.

When the workflow completes successfully, a push to main results in a new Gatsby build being published to S3 and served through CloudFront without a manual deployment step.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like