Post

Hosting a Jekyll Site on AWS with Terraform, S3 & CloudFront

How I set up guydevops.com — a Jekyll blog served from S3 behind CloudFront, provisioned with Terraform. Runs for under $2/month.

Hosting a Jekyll Site on AWS with Terraform, S3 & CloudFront

Tech Stack

Jekyll
Jekyll
Terraform
Terraform
AWS
AWS

This is how I set up guydevops.com — a Jekyll blog sitting in an S3 bucket, served through CloudFront, with DNS and TLS handled by Route 53 and ACM. Everything is provisioned with Terraform and the whole thing costs under $2/month.

Why This Setup

Problem How this stack handles it
Patching, scaling, paying for servers Jekyll produces static HTML. No servers to manage.
Click-ops and config drift Terraform codifies the infrastructure. Version-controlled and reviewable.
Slow loads for visitors far from origin CloudFront caches at 400+ edge locations.
TLS certificate renewals ACM issues free certs and auto-renews them.
Vendor lock-in It’s static files. Move them to Netlify, Vercel, or anywhere else whenever.

Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌──────────────────────────────────────────────────────┐
│                    User Browser                       │
└──────────────┬───────────────────────────────────────┘
               │ 1. DNS Query (guydevops.com)
               ▼
┌──────────────────────────────────────────────────────┐
│              Route 53 (DNS)                           │
│              Returns CloudFront IP                    │
└──────────────┬───────────────────────────────────────┘
               │ 2. HTTPS Request
               ▼
┌──────────────────────────────────────────────────────┐
│            CloudFront (CDN)                           │
│            400+ Edge Locations                        │
│            TLS via ACM Certificate                    │
└──────────────┬───────────────────────────────────────┘
               │ 3. Origin fetch (cache miss only)
               ▼
┌──────────────────────────────────────────────────────┐
│            S3 Bucket (Static Website)                 │
│            Jekyll _site/ output                       │
└──────────────────────────────────────────────────────┘

Terraform manages all of the above.
State stored in a separate S3 bucket.

Everything lives in us-east-1 because CloudFront requires ACM certificates in that region.


Prerequisites

  • Ruby 3.1 or 3.2 — 3.3+ has stdlib changes that break Jekyll 4.x
  • AWS account with IAM access
  • AWS CLI and Terraform >= 1.2
  • A registered domain with a Route 53 hosted zone

1. Create the Jekyll Site

I use the Chirpy theme as a gem dependency. Get it running locally first:

1
2
3
4
git clone https://github.com/<YOU>/<REPO>.git && cd <REPO>
gem install bundler jekyll
bundle install
bundle exec jekyll serve   # verify at http://127.0.0.1:4000

Edit _config.yml with your site info, write posts in _posts/, then build:

1
jekyll build   # outputs to _site/

Keep _site in .gitignore — we sync it to S3 separately.


2. Set Up Remote State

Before writing any Terraform:

  1. Create a private S3 bucket (mine is called guy-terraform-state)
  2. Enable versioning for state history
  3. Optionally add a DynamoDB table for state locking

3. Terraform

Here are the key files. Full source is in my repo.

main.tf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }
  backend "s3" {
    bucket = "guy-terraform-state"
    key    = "terraform"
    region = "us-east-1"
  }
  required_version = ">= 1.2.0"
}

provider "aws" {
  region = "us-east-1"
}
s3.tf — Bucket with Referer-based access policy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
variable "secret_header" {
  type    = string
  default = "secret-header"   # prevents direct bucket access
}

variable "site_path" {
  type    = string
  default = "~/repos/guydevops.com"
}

resource "aws_s3_bucket" "guydevops-com_s3_bucket" {
  bucket = "guydevops.com"
  tags   = { Name = "guydevops", Environment = "prod" }
}

resource "aws_s3_bucket_website_configuration" "guydevops-com_s3_bucket_website" {
  bucket = aws_s3_bucket.guydevops-com_s3_bucket.id
  index_document { suffix = "index.html" }
}

# Only allow reads when the Referer header matches.
# CloudFront injects this header on every origin request.
resource "aws_s3_bucket_policy" "allow_public_access_to_site" {
  bucket = aws_s3_bucket.guydevops-com_s3_bucket.id
  policy = data.aws_iam_policy_document.allow_public_access_to_site_policy.json
}

data "aws_iam_policy_document" "allow_public_access_to_site_policy" {
  statement {
    principals { type = "*" identifiers = ["*"] }
    actions   = ["s3:GetObject"]
    resources = [
      aws_s3_bucket.guydevops-com_s3_bucket.arn,
      "${aws_s3_bucket.guydevops-com_s3_bucket.arn}/*"
    ]
    condition {
      test     = "StringLike"
      variable = "aws:Referer"
      values   = [var.secret_header]
    }
  }
}

# Syncs the built site to S3 on every terraform apply
resource "null_resource" "remove_and_upload_to_s3" {
  triggers    = { always_run = timestamp() }
  provisioner "local-exec" {
    command = "aws s3 sync ${pathexpand(var.site_path)}/_site s3://${aws_s3_bucket.guydevops-com_s3_bucket.id}"
  }
}
acm_cert.tf — TLS certificate with DNS validation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
data "aws_route53_zone" "guydevops_zone" { name = "guydevops.com" }

resource "aws_acm_certificate" "guydevops_cert" {
  domain_name       = "guydevops.com"
  validation_method = "DNS"
}

resource "aws_route53_record" "guydevops_cert_record" {
  for_each = {
    for dvo in aws_acm_certificate.guydevops_cert.domain_validation_options :
    dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.guydevops_zone.zone_id
}

resource "aws_acm_certificate_validation" "guydevops_cert_validation" {
  certificate_arn         = aws_acm_certificate.guydevops_cert.arn
  validation_record_fqdns = [for r in aws_route53_record.guydevops_cert_record : r.fqdn]
}

cloudfront.tf

CloudFront uses a custom origin (the S3 website endpoint, not the REST API endpoint) and injects the secret Referer header so the bucket policy allows the request through.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
resource "aws_cloudfront_distribution" "guydevops_cf_dis" {
  origin {
    domain_name = aws_s3_bucket_website_configuration.guydevops-com_s3_bucket_website.website_endpoint
    origin_id   = "S3Origin"
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
    custom_header { name = "Referer" value = var.secret_header }
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  aliases             = ["guydevops.com"]

  default_cache_behavior {
    target_origin_id       = "S3Origin"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    forwarded_values {
      query_string = false
      cookies { forward = "none" }
    }
  }

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

  restrictions {
    geo_restriction {
      restriction_type = "blacklist"
      locations        = ["RU", "CN", "KP", "IR"]
    }
  }
}

resource "aws_route53_record" "guydevops_record" {
  name    = "guydevops.com"
  type    = "A"
  zone_id = data.aws_route53_zone.guydevops_zone.zone_id
  alias {
    name                   = aws_cloudfront_distribution.guydevops_cf_dis.domain_name
    zone_id                = aws_cloudfront_distribution.guydevops_cf_dis.hosted_zone_id
    evaluate_target_health = false
  }
}

4. Deploy

1
2
3
4
jekyll build          # generate _site/
terraform init        # pull providers, configure backend
terraform plan        # review what gets created
terraform apply       # provision everything

Takes about 5 minutes. Most of that is CloudFront distribution deployment.


5. Automate Deployments

Instead of running terraform apply every time you publish a post, set up GitHub Actions to build the site and sync to S3 on push. I have a deploy workflow that does this using OIDC for AWS authentication — no static credentials needed.


What It Costs

Resource Monthly
S3 Storage (1 GB) $0.02
CloudFront (10 GB transfer) $0.85
ACM Certificate Free
Route 53 Hosted Zone $0.50
Total < $2/month

Assumes moderate traffic. Even at higher volumes, static hosting on AWS stays cheap.


Wrapping Up

You end up with a static site on a global CDN with auto-renewing TLS, managed entirely through Terraform. No servers to patch, nothing to scale, and it costs less than a coffee.

Some things worth adding:

  • www subdomain — add it as a CloudFront alias with a redirect
  • CloudWatch alarms — get notified on 4xx/5xx spikes
  • WAF — if you want DDoS protection beyond what CloudFront provides by default

Troubleshooting notes:

  • CloudFront cache changes can take 5-15 minutes to propagate (or invalidate manually)
  • Route 53 DNS changes can take up to 48 hours
  • If Jekyll builds fail, check Ruby version — stick with 3.1 or 3.2

Questions? Find me on GitHub or LinkedIn.

This post is licensed under CC BY 4.0 by the author.