Home β€Ί Blog β€Ί How to Deploy a Jekyll Site to AWS (S3 + CloudFront Guide)
Tutorial

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.

How to Deploy a Jekyll Site to AWS (S3 + CloudFront Guide)

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

  1. Go to the AWS S3 console
  2. Click Create bucket
  3. Set Bucket name to your domain (e.g. jekyllhub.com) β€” use lowercase only
  4. Set AWS Region to the region closest to you (e.g. us-east-1)
  5. Under Block Public Access, uncheck Block all public access (CloudFront needs to read the files)
  6. Acknowledge the warning and click Create bucket

Enable static website hosting

  1. Click your bucket β†’ Properties tab
  2. Scroll to Static website hosting β†’ Edit
  3. Select Enable
  4. Set Index document: index.html
  5. Set Error document: 404.html
  6. 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

  1. Go to the ACM console β€” must be in us-east-1 (CloudFront requires this)
  2. Click Request a certificate β†’ Request a public certificate
  3. Add your domain names: jekyllhub.com and www.jekyllhub.com
  4. Choose DNS validation
  5. Click Request
  6. Add the CNAME records shown to your DNS provider
  7. Wait for status to show Issued (usually 5–30 minutes)

Step 5: Create a CloudFront distribution

  1. Go to the CloudFront console
  2. Click Create distribution
  3. 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)
  4. Under Default cache behavior:
    • Viewer protocol policy: Redirect HTTP to HTTPS
    • Allowed HTTP methods: GET, HEAD
    • Cache policy: CachingOptimized
  5. Under Settings:
    • Alternate domain names (CNAMEs): Add jekyllhub.com and www.jekyllhub.com
    • Custom SSL certificate: Select the ACM certificate you just created
    • Default root object: index.html
  6. 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:

  1. Create a hosted zone for your domain
  2. Create an A record as an alias pointing to your CloudFront distribution
  3. 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:

  1. Go to CloudFront β†’ Functions β†’ Create function
  2. Name it jekyll-url-rewrite
  3. 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;
}
  1. Click Save changes β†’ Publish
  2. 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_ID
  • AWS_SECRET_ACCESS_KEY
  • S3_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:

  1. IAM β†’ Users β†’ Create user β†’ name it jekyll-deploy
  2. 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"
    }
  ]
}
  1. 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.

Share LinkedIn