How to Deploy a Gatsby Site to AWS with GitHub Actions
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.
{
"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.
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.
*.tfstate*
Add GitHub Secrets
Before the workflow can deploy anything, GitHub needs the AWS credentials and deployment targets.
AWS_ACCESS_KEY_IDis the IAM access key ID.AWS_SECRET_ACCESS_KEYis the matching secret.AWS_BUCKET_NAMEis the name of the S3 bucket that stores the site.AWS_CLOUDFRONT_DISTRIBUTION_IDis the distribution that serves the site.AWS_REGIONshould match the Terraform region, which isus-east-1in 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:
- Build the Gatsby site on every push or pull request.
- Deploy only when the event is a direct push to the main branch.
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.
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 syncupdates only the files that changed, and--deleteremoves 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.