How to Host a Gatsby Site on AWS with Terraform
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
wwwdomains - AWS Certificate Manager issues the TLS certificate that CloudFront needs
- Lambda@Edge redirects the non-
wwwhostname to the canonicalwwwhostname
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
- Terraform creates the bucket, distribution, certificate, and DNS records.
- Gatsby builds static assets into
public/. - The build artifacts are uploaded to S3.
- 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.