skies.dev

How to Host a Gatsby Site on AWS with Terraform

5 min read

Setting Up to Host a Gatsby Site on AWS with Terraform

This article walks through the infrastructure needed to host a Gatsby site on AWS using Terraform from the start. The stack is small but there are several moving parts, so it helps to see the whole layout before writing any code:

  • S3 stores the static files generated by Gatsby
  • CloudFront serves the site over HTTPS and caches content at the edge
  • Route 53 handles DNS for the root and www domains
  • AWS Certificate Manager issues the TLS certificate that CloudFront needs
  • Lambda@Edge redirects the non-www hostname to the canonical www hostname

All of the examples below assume you are deploying in us-east-1. That region matters because ACM certificates used by CloudFront must live there, and Lambda@Edge functions are also published from there.

The code snippets are based on a terraform/ folder at the root of the project. You can name the folder differently if you want, but keeping the Terraform code isolated makes the workflow easier to follow.

Start with Shared Values

I keep the domain and region values in locals.tf so the rest of the configuration can reuse them.

terraform/locals.tf
locals {
  s3_bucket_name        = "www.yourdomain.com"
  primary_domain_name   = "www.yourdomain.com"
  alternate_domain_name = "yourdomain.com"
  region                = "us-east-1"
  cloudfront_ttl        = 31536000
}

The bucket name should match the hosted site name, because the bucket will store the Gatsby build output directly.

terraform/provider.tf
provider "aws" {
  region = local.region
}

Create the S3 Origin

The S3 bucket is the origin for the site. We disable the public access block because CloudFront will reach the bucket through the website endpoint, and we add a bucket policy that allows anonymous GetObject access.

terraform/s3.tf
resource "aws_s3_bucket" "my_bucket" {
  bucket = local.s3_bucket_name

  website {
    index_document = "index.html"
    error_document = "404.html"
  }
}

resource "aws_s3_bucket_public_access_block" "my_bucket_public_access_block" {
  bucket = aws_s3_bucket.my_bucket.id

  block_public_acls   = false
  block_public_policy = false
}

resource "aws_s3_bucket_policy" "my_bucket_policy" {
  depends_on = [aws_s3_bucket_public_access_block.my_bucket_public_access_block]

  bucket = aws_s3_bucket.my_bucket.id
  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicRead",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "${aws_s3_bucket.my_bucket.arn}/*"
    }
  ]
}
POLICY
}

That bucket policy is intentionally narrow: it only grants read access to the objects that CloudFront will serve.

Put CloudFront in Front of S3

CloudFront gives us HTTPS, caching, and a clean public URL. The S3 website endpoint is configured as a custom origin, and the cache TTL is set to one year because Gatsby output is static and can be invalidated when we deploy new content.

terraform/cloudfront.tf
locals {
  s3_origin_id = "myS3Origin"
}

resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.my_bucket.website_endpoint
    origin_id   = local.s3_origin_id

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  enabled         = true
  is_ipv6_enabled = true
  comment         = "Gatsby site CloudFront distribution"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    compress         = true
    target_origin_id  = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = local.cloudfront_ttl
    default_ttl            = local.cloudfront_ttl
    max_ttl                = local.cloudfront_ttl
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

At this point you can build Gatsby, upload the generated public/ directory to S3, and serve the site through the CloudFront distribution.

Add a Custom Domain

A real site usually needs more than the CloudFront domain name. For production use, I add DNS records for both the canonical www host and the root domain, then point the root domain to the same site.

terraform/locals.tf
locals {
  s3_bucket_name        = "www.yourdomain.com"
  primary_domain_name   = "www.yourdomain.com"
  alternate_domain_name = "yourdomain.com"
  region                = "us-east-1"
  cloudfront_ttl        = 31536000
}

The Route 53 zone creates the DNS surface for the site.

terraform/route53.tf
resource "aws_route53_zone" "main" {
  name = local.alternate_domain_name
}

resource "aws_route53_record" "www" {
  zone_id = aws_route53_zone.main.zone_id
  name    = local.primary_domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.s3_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_route53_record" "apex" {
  zone_id = aws_route53_zone.main.zone_id
  name    = local.alternate_domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.s3_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

For CloudFront to answer on a custom hostname, it also needs a certificate from ACM. That certificate must be issued in us-east-1.

terraform/acm.tf
resource "aws_acm_certificate" "site" {
  domain_name       = local.primary_domain_name
  validation_method = "DNS"

  subject_alternative_names = [local.alternate_domain_name]
}

In practice, you would add DNS validation records from the ACM validation data, wait for issuance, and then attach the certificate to the CloudFront distribution.

terraform/cloudfront.tf
resource "aws_cloudfront_distribution" "s3_distribution" {
  // ...existing origin and cache behavior...

  aliases = [local.primary_domain_name, local.alternate_domain_name]

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.site.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

Redirect the Apex Domain

I prefer to keep www as the canonical hostname and redirect the bare domain to it. Rather than creating a second S3 bucket, this version uses Lambda@Edge to redirect requests at the viewer-request stage.

terraform/lambda/redirect.js
exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const host = request.headers.host[0].value;

  if (host === 'yourdomain.com') {
    return {
      status: '301',
      statusDescription: 'Moved Permanently',
      headers: {
        location: [
          {
            key: 'Location',
            value: `https://www.yourdomain.com${request.uri}`,
          },
        ],
      },
    };
  }

  return request;
};

Then attach that Lambda function to the CloudFront distribution.

terraform/cloudfront.tf
resource "aws_cloudfront_distribution" "s3_distribution" {
  // ...existing configuration...

  default_cache_behavior {
    // ...existing cache behavior...

    lambda_function_association {
      event_type   = "viewer-request"
      lambda_arn   = aws_lambda_function.redirect_lambda.qualified_arn
      include_body = false
    }
  }
}

Closing Thoughts

With those pieces in place, the Gatsby deployment path is straightforward:

  1. Terraform creates the bucket, distribution, certificate, and DNS records.
  2. Gatsby builds static assets into public/.
  3. The build artifacts are uploaded to S3.
  4. CloudFront serves the site and the Lambda@Edge redirect keeps the hostname canonical.

The next step is to automate the deployment itself. In the follow-up post, I set up GitHub Actions to publish each Gatsby build to S3 and invalidate CloudFront automatically.

Continue to the deployment tutorial

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