How to Deploy a Jekyll Site to AWS (S3 + CloudFront Guide)
Host your Jekyll site on AWS S3 with CloudFront CDN β a complete guide covering S3 bucket setup, CloudFront distribution, custom domains with Route 53, SSL, and CI/CD with GitHub Actions.
Deploying a Jekyll site to AWS gives you enterprise-grade infrastructure with granular control over every aspect of your hosting. The standard setup combines S3 (storage) with CloudFront (CDN) β effectively the same architecture that powers large commercial sites, available for a Jekyll blog at a cost that is often under $1 per month.
Why deploy Jekyll to AWS
- Near-zero cost β S3 + CloudFront for a typical Jekyll blog costs $0.10β$2/month depending on traffic
- Enterprise reliability β AWS S3 has 99.999999999% durability; CloudFront has 450+ global edge locations
- Full control β custom cache policies, custom headers, Lambda@Edge for edge functions
- No vendor lock-in β standard HTTP hosting, easy to migrate
- Scales infinitely β handles 10 visitors or 10 million with the same configuration
The trade-off: setup is more involved than Netlify or Cloudflare Pages. It is the right choice when you need maximum control or are already using AWS for other infrastructure.
Architecture overview
Browser β CloudFront (CDN, SSL, caching)
β
S3 Bucket (static file storage)
CloudFront serves files from the edge closest to each visitor. On cache miss, it fetches from S3. S3 stores your _site/ output.
AWS services used
- S3 β stores your static files
- CloudFront β CDN and HTTPS termination
- ACM (Certificate Manager) β free SSL/TLS certificates
- Route 53 (optional) β DNS management
- IAM β access credentials for automated deploys
Step 1: Build your Jekyll site
JEKYLL_ENV=production bundle exec jekyll build
Verify _site/ contains your built output before proceeding.
Step 2: Create an S3 bucket
- Go to the AWS S3 console
- Click Create bucket
- Set Bucket name to your domain (e.g.
jekyllhub.com) β use lowercase only - Set AWS Region to the region closest to you (e.g.
us-east-1) - Under Block Public Access, uncheck Block all public access (CloudFront needs to read the files)
- Acknowledge the warning and click Create bucket
Enable static website hosting
- Click your bucket β Properties tab
- Scroll to Static website hosting β Edit
- Select Enable
- Set Index document:
index.html - Set Error document:
404.html - Save
Note the Bucket website endpoint β you will need it for CloudFront.
Add a bucket policy for public read access
In your bucket β Permissions tab β Bucket policy β Edit, paste:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::jekyllhub.com/*"
}
]
}
Replace jekyllhub.com with your bucket name.
Step 3: Upload your site to S3
Using the AWS CLI (recommended β much faster than the console for many files):
# Install AWS CLI if not already installed
pip install awscli
# Configure with your credentials
aws configure
# Sync your _site/ folder to S3
aws s3 sync _site/ s3://jekyllhub.com --delete --cache-control "public, max-age=86400" --exclude "*.html" --exclude "*.xml" --exclude "*.json"
# Upload HTML files with no-cache (so updates are seen immediately)
aws s3 sync _site/ s3://jekyllhub.com --delete --cache-control "no-cache, no-store, must-revalidate" --include "*.html" --include "*.xml" --include "*.json"
This two-pass approach caches assets (CSS, JS, images) aggressively while ensuring HTML is always fresh.
Step 4: Request an SSL certificate in ACM
- Go to the ACM console β must be in us-east-1 (CloudFront requires this)
- Click Request a certificate β Request a public certificate
- Add your domain names:
jekyllhub.comandwww.jekyllhub.com - Choose DNS validation
- Click Request
- Add the CNAME records shown to your DNS provider
- Wait for status to show Issued (usually 5β30 minutes)
Step 5: Create a CloudFront distribution
- Go to the CloudFront console
- Click Create distribution
- Under Origin:
- Origin domain: Paste your S3 website endpoint (not the bucket ARN β use the website endpoint that looks like
jekyllhub.com.s3-website-us-east-1.amazonaws.com) - Protocol: HTTP only (S3 website endpoints do not support HTTPS origin β CloudFront handles SSL at the edge)
- Origin domain: Paste your S3 website endpoint (not the bucket ARN β use the website endpoint that looks like
- Under Default cache behavior:
- Viewer protocol policy: Redirect HTTP to HTTPS
- Allowed HTTP methods: GET, HEAD
- Cache policy: CachingOptimized
- Under Settings:
- Alternate domain names (CNAMEs): Add
jekyllhub.comandwww.jekyllhub.com - Custom SSL certificate: Select the ACM certificate you just created
- Default root object:
index.html
- Alternate domain names (CNAMEs): Add
- Click Create distribution
The distribution takes 5β15 minutes to deploy globally. You will see a CloudFront domain like d1234abcd.cloudfront.net.
Step 6: Configure DNS
If using Route 53:
- Create a hosted zone for your domain
- Create an A record as an alias pointing to your CloudFront distribution
- Update your domain registrar to use Route 53 nameservers
If using another DNS provider:
- Create a CNAME record:
wwwβd1234abcd.cloudfront.net - For the root domain, use your registrarβs ALIAS or ANAME feature pointing to the CloudFront domain
Step 7: Handle clean URLs and 404s
Jekyll generates about/index.html for a page at /about/. CloudFront serves index.html at the root but not in subdirectories by default β navigating directly to /about/ returns a 403.
Fix this with a CloudFront Function:
- Go to CloudFront β Functions β Create function
- Name it
jekyll-url-rewrite - Paste this code:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Add index.html to directory requests
if (uri.endsWith("/")) {
request.uri += "index.html";
} else if (!uri.includes(".")) {
request.uri += "/index.html";
}
return request;
}
- Click Save changes β Publish
- In your CloudFront distribution β Behaviors β Edit β Function associations β Viewer request β select your function
This rewrites /about/ to /about/index.html at the edge before CloudFront looks up the file.
Step 8: Automate deploys with GitHub Actions
# .github/workflows/deploy.yml
name: Deploy Jekyll to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true
- name: Build Jekyll site
run: JEKYLL_ENV=production bundle exec jekyll build
- name: Configure AWS credentials
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: us-east-1
- name: Sync to S3 (assets β long cache)
run: |
aws s3 sync _site/ s3://${{ secrets.S3_BUCKET }} --delete --cache-control "public, max-age=31536000, immutable" --exclude "*.html" --exclude "*.xml" --exclude "*.json" --exclude "*.txt"
- name: Sync to S3 (HTML β no cache)
run: |
aws s3 sync _site/ s3://${{ secrets.S3_BUCKET }} --cache-control "no-cache, no-store, must-revalidate" --include "*.html" --include "*.xml" --include "*.json" --include "*.txt"
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
Add these secrets to your GitHub repository:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYS3_BUCKET(your bucket name)CLOUDFRONT_DISTRIBUTION_ID
Create an IAM user for GitHub Actions
Never use your root AWS credentials in GitHub Actions. Create a dedicated IAM user:
- IAM β Users β Create user β name it
jekyll-deploy - Attach this policy directly:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::jekyllhub.com",
"arn:aws:s3:::jekyllhub.com/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
}
]
}
- Create access keys for this user and store them as GitHub secrets.
Cost estimate
For a typical Jekyll blog with 10,000 monthly visitors:
| Service | Monthly cost |
|---|---|
| S3 storage (100MB) | ~$0.002 |
| S3 requests | ~$0.05 |
| CloudFront data transfer (1GB) | ~$0.085 |
| CloudFront requests (100k) | ~$0.008 |
| ACM certificate | Free |
| Route 53 hosted zone | $0.50 |
| Total | ~$0.65/month |
CloudFront has a free tier: 1TB data transfer and 10 million requests per month free for the first 12 months.
Troubleshooting
403 Forbidden on CloudFront
Check the bucket policy allows s3:GetObject for all principals, and you used the S3 website endpoint (not the REST endpoint) as the CloudFront origin.
Subdirectory pages return 403 or 404
Ensure you have the CloudFront Function rewriting URLs to index.html β see Step 7.
Old content still showing after deploy
The CloudFront cache invalidation in the GitHub Actions workflow clears this. If running manually, create an invalidation for /* in the CloudFront console.
SSL certificate not available in CloudFront
ACM certificates must be in the us-east-1 region to be usable with CloudFront. If you created it in another region, request a new one in us-east-1.
AWS S3 + CloudFront is the most scalable and cost-effective way to host a Jekyll site when you need full control over your infrastructure. The setup is more complex than Netlify or Cloudflare Pages, but once the GitHub Actions workflow is in place, deployments are fully automated.
Automating cache invalidation efficiently
The CloudFront cache invalidation in the deployment workflow (aws cloudfront create-invalidation --paths "/*") clears every cached file after every deploy. This is safe but inefficient β if you deploy a single post update, there is no reason to invalidate cached images, CSS, and JavaScript files that did not change.
A more precise approach invalidates only the files that changed in the most recent build. Git provides this information via git diff --name-only HEAD~1 HEAD, which lists files modified in the last commit. Convert those file paths to CloudFront paths and invalidate only them. The trade-off is additional workflow complexity, and invalidation is cheap (the first 1,000 invalidation paths per month are free), so the precise approach is only worth implementing at large scale.
An alternative to path-based invalidation is cache versioning via content-addressed filenames. Append a content hash to asset filenames (Jekyllβs assets/css/main-a1b2c3.css pattern with a plugin like jekyll-assets) and set long cache headers (one year) on those files. When assets change, the filename changes, so CloudFront treats them as new files β no invalidation needed. HTML files, which reference the versioned assets, get short cache headers (5 minutes) and are invalidated on deploy. This is the highest-performance caching architecture but requires more configuration.
Using AWS Lambda@Edge for dynamic functionality
Jekyll produces static files, but Lambda@Edge lets you add server-side logic at the CloudFront edge layer without an origin server. Common uses for Jekyll sites include A/B testing (serve different page variants based on a cookie), personalisation (insert user-specific content from a query parameter), authentication-gated content (redirect to login if no session cookie is present), and header manipulation (add security headers like CSP to every response).
Lambda@Edge functions are deployed to CloudFront as viewer request, origin request, viewer response, or origin response triggers. For a Jekyll site, viewer request functions (which run before the cache is checked) are useful for authentication and routing; viewer response functions (which run after the response is served from cache) are useful for adding security headers.
The security header use case is particularly relevant: add X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a Content-Security-Policy header to every response via a viewer response function. This improves your siteβs security posture without modifying Jekyllβs output.
Lambda@Edge functions must be deployed to us-east-1 (regardless of where your S3 bucket or primary CloudFront distribution is), must complete within 5ms (viewer request/response) or 30ms (origin request/response), and are billed per request plus execution duration. For a typical Jekyll blog, the costs are negligible.
Monitoring and alerting for your AWS Jekyll stack
Once your site is live on S3 + CloudFront, CloudWatch provides monitoring for the stack. The metrics most relevant to a static site are CloudFront 4xxErrorRate and 5xxErrorRate (broken links and origin errors), CloudFront BytesDownloaded (bandwidth cost tracking), and S3 NumberOfObjects and BucketSizeBytes (storage cost tracking).
Set up a CloudWatch alarm on 5xxErrorRate > 1% to notify you (via SNS email or SMS) when CloudFront is failing to serve content from S3. A 5xx from CloudFront on a static site typically indicates the S3 bucket policy is incorrect or the origin configuration is broken β catching this quickly prevents extended downtime.
AWS Cost Explorer provides month-to-date cost breakdowns by service. For a Jekyll site, your bill should be dominated by Route 53 (the $0.50/month hosted zone fee) with S3 and CloudFront costs in the cents. If CloudFront costs spike unexpectedly, check for a hotlinked asset or a DDoS-like traffic event and use CloudFrontβs geographic restriction or WAF integration to block the source.