Host a Lightning‑Fast Jekyll Site on AWS with Terraform, S3 & CloudFront
Comprehensive, step‑by‑step guide to building and deploying a secure, globally‑distributed Jekyll website using Terraform, Amazon S3, CloudFront, and Route 53 — complete with diagrams and visuals.
Goal: Serve https://guydevops.com from a zero‑maintenance static stack that costs pennies a month yet scales to millions of page views.
Why This Architecture?
Pain Point | How the Stack Solves It |
---|---|
Patching, scaling, or paying for servers | Jekyll produces plain HTML; no servers to manage. |
Repetitive click-ops & drift | Terraform codifies infrastructure (version-controlled, reviewable). |
Global latency & SEO ranking | CloudFront caches content at 400+ PoPs → lightning TTFB. |
TLS certificate renewals | ACM issues & auto-renews free certificates. |
Vendor lock-in | Static assets can be migrated anywhere (Netlify, Vercel, Azure Blob Storage…) |
High‑Level 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🌐 User Browser │
└─────────────────────────┬───────────────────────────────────────────────────┘
│
│ 1. DNS Query (guydevops.com)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📍 Route 53 (DNS) │
│ Returns CloudFront IP │
└─────────────────────────┬───────────────────────────────────────────────────┘
│
│ 2. HTTPS Request
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🚀 CloudFront (CDN) │
│ 400+ Global Edge Locations │
│ ├─ 🔒 ACM Certificate │
│ └─ Cache: HTML, CSS, JS, Images │
└─────────────────────────┬───────────────────────────────────────────────────┘
│
│ 3. Origin Request (cache miss)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🪣 S3 Static Website │
│ Static Files: Jekyll Build │
│ index.html, assets/, _site/ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🏗️ Terraform Management │
│ │
│ Terraform CLI/CI ──────────────┐ │
│ │ │ │
│ │ ▼ │
│ │ 📦 S3 Remote State │
│ │ │
│ └─── Creates & Manages ───┐ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Route 53 CloudFront S3 │
│ │ │
│ └─── 🔒 ACM Certificate │
└─────────────────────────────────────────────────────────────────────────────┘
Key Benefits:
- Global CDN: CloudFront serves from 400+ edge locations
- Cost-effective: ~$2/month for most personal sites
- Secure: Free SSL certificates with auto-renewal
- Scalable: Handles traffic spikes automatically
- Infrastructure as Code: Everything version-controlled
All resources are created in us‑east‑1 to satisfy CloudFront’s ACM regional requirement.
Prerequisites
-
Ruby 3.1 / 3.2 (recommended) — 3.3+ splits stdlib; Jekyll 4.x still assumes built‑in
csv
. - AWS account with basic IAM familiarity
- AWS CLI & Terraform ≥ 1.2 installed
- Git & GitHub
- A registered domain and a Route 53 hosted zone (e.g.,
guydevops.com
)
1 Create the Jekyll Site (Chirpy Theme)
- Fork the Chirpy starter (or clone directly).
- Install dependencies & preview locally:
1 2 3 4 5
git clone https://github.com/<YOU>/<REPO>.git && cd <REPO> rbenv local 3.2.2 # if you downgraded Ruby gem install bundler jekyll bundle install bundle exec jekyll s # http://127.0.0.1:4000
- Edit
_config.yml
(site title, description, social links) and write posts in_posts/
using Markdown. - Generate static HTML:
1
jekyll build # outputs → _site/
Tip: Commit the
_site
directory to.gitignore
; we’ll sync it directly to S3 via Terraform or CI.
2 Prepare AWS for Remote State
-
Create a private S3 bucket called
guy-terraform-state
. - Enable versioning for state history.
- (Optional) Create a DynamoDB table for state locking.
- Configure credentials (profile or IAM role) so Terraform can read/write state.
3 Infrastructure as Code
Here are the key Terraform files. You can grab the complete versions from 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 — Static‑Site Bucket (existing name preserved)
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
variable "secret_header" {
type = string
default = "secret-header" # keep secret this is to prevent direct access to the bucket
}
variable "site_path" {
type = string
default = "~/repos/guydevops.com"
}
resource "aws_s3_bucket" "guydevops-com_s3_bucket" {
bucket = "guydevops.com" # DO NOT CHANGE
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" }
}
# Bucket policy allows only CloudFront (Referer header) to read
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]
}
}
}
# Quick‑and‑dirty deploy — replace with CI for production!
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
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
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 # download providers & configure backend
terraform plan # review infra changes
terraform apply # confirm & provision
Provisioning takes ≈5 minutes.
5 Automate Updates
Add a GitHub Actions workflow that:
- Checks out your repo.
- Caches Ruby gems.
- Runs
jekyll build
. - Uploads the
_site
folder to an artifact or directly to S3. - Executes
terraform apply -auto-approve
.
Cost Snapshot
Ultra-Low Cost Hosting
Monthly cost breakdown:
- S3 Storage (1 GB): $0.023
- CloudFront (10 GB out): $0.85 (US East prices)
- SSL Certificate (ACM): FREE
- Route 53 Hosted Zone: $0.50
- Total: < $2/month
Cost Note: This assumes moderate traffic (10GB/month CloudFront data transfer). For higher traffic, costs scale linearly but remain very affordable compared to traditional hosting.
Wrapping Up
That's a Wrap!
Your Jekyll site is now running on a secure, CDN‑accelerated stack that delivers enterprise-grade performance for under $2/month.
What We Built:
You’ve built a secure, CDN‑accelerated Jekyll site that:
- Global reach — CloudFront serves content from 400+ edge locations worldwide
- Dirt cheap hosting — Under $2/month for most personal sites
- Zero server maintenance — No patching, no monitoring, no headaches
- Everything in Git — Infrastructure and content both version controlled
- SSL by default — Free certificates that renew automatically
Next Steps
-
Custom domain: Add
www.guydevops.com
as an alias in CloudFront -
CI/CD: Replace the
null_resource
with GitHub Actions for automated deployments - Monitoring: Add CloudWatch alarms for 4xx/5xx errors
- Performance: Enable Gzip compression and optimize images
- Security: Consider adding AWS WAF for DDoS protection
Troubleshooting
- CloudFront cache: Changes may take 5-15 minutes to propagate globally
- DNS propagation: Route 53 changes can take up to 48 hours
- Jekyll build errors: Check Ruby version compatibility (3.1-3.2 recommended)
Questions or run into issues? Hit me up on GitHub or LinkedIn.