You know that feeling when you visit a website and it loads so fast you question whether your internet is suddenly working properly for once?

That's the CloudFront effect.

Imagine this: Your website's files sitting in one place (S3 bucket in, say, us-east-1), but being served from locations near your users all over the world - Nairobi, London, Tokyo, São Paulo. Someone in Mombasa hits your site, and CloudFront serves it from the nearest edge location in milliseconds.

It's like having a kiosk in every matatu stage in the world, all selling your product. Customer walks up, grabs what they need, pays the same price, gets it instantly. That's CloudFront - a content delivery network (CDN) that makes your website stupid fast globally.

Today we're building a complete production setup: static website on S3, distributed via CloudFront, with custom domain and HTTPS. This is how the pros do it.

What We're Building

A static website hosting setup with:

  • S3 bucket for storage
  • CloudFront distribution for global delivery
  • Custom domain (optional) with Route 53
  • HTTPS/SSL certificate (free via AWS Certificate Manager)
  • Cache optimization for performance
  • Security best practices with Origin Access Control (OAC)

This builds on S3 object storage fundamentals - read that first if you're new to S3.

Why S3 + CloudFront Instead of Regular Hosting?

Speed: CloudFront has 400+ edge locations worldwide. Your site is cached close to users, reducing latency from hundreds of milliseconds to single-digit milliseconds.

Scalability: Your site goes viral? CloudFront handles it. No server crashes, no capacity planning. AWS scales automatically.

Cost: S3 storage is dirt cheap ($0.023/GB/month). CloudFront free tier gives you 1TB data transfer/month free for a year. After that, it's still cheap compared to traditional hosting.

Security: Built-in DDoS protection, HTTPS by default, and you can restrict S3 access so only CloudFront can serve files.

Reliability: AWS's 99.99% uptime SLA. Your site stays up even if an entire AWS region goes down.

Prerequisites

  • AWS account
  • Domain name (optional, but recommended)
  • Static website files (HTML, CSS, JS, images)
  • AWS CLI installed and configured

# Verify AWS CLI is configured
aws sts get-caller-identity

Step 1: Create and Configure S3 Bucket

Create an S3 bucket for your website:

# Replace 'my-awesome-website' with your unique bucket name
BUCKET_NAME="my-awesome-website"
REGION="us-east-1"

aws s3api create-bucket \
  --bucket $BUCKET_NAME \
  --region $REGION

Important: Bucket names must be globally unique. If you get an error, try a different name.

Enable versioning (optional, but useful for rollbacks):

aws s3api put-bucket-versioning \
  --bucket $BUCKET_NAME \
  --versioning-configuration Status=Enabled

Step 2: Upload Website Files

Upload your static website files:

# If your website is in a 'dist' or 'build' folder
aws s3 sync ./dist s3://$BUCKET_NAME/ \
  --delete \
  --cache-control "max-age=31536000" \
  --exclude "*.html" \
  --exclude "*.xml"

# Upload HTML files with shorter cache (they change more often)
aws s3 sync ./dist s3://$BUCKET_NAME/ \
  --exclude "*" \
  --include "*.html" \
  --include "*.xml" \
  --cache-control "max-age=3600" \
  --content-type "text/html"

Why different cache settings?

  • Static assets (CSS, JS, images) rarely change - cache for 1 year
  • HTML files change frequently - cache for 1 hour
  • This gives you speed without serving stale content

Set up a simple test website if you don't have one:

# Create a simple HTML file
cat > index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Awesome Website</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 { font-size: 3rem; margin: 0; }
        p { font-size: 1.5rem; opacity: 0.9; }
    </style>
</head>
<body>
    <div class="container">
        <h1>🚀 It Works!</h1>
        <p>Your S3 + CloudFront setup is live</p>
        <p><small>Served via AWS CloudFront CDN</small></p>
    </div>
</body>
</html>
EOF

# Upload to S3
aws s3 cp index.html s3://$BUCKET_NAME/index.html \
  --content-type "text/html" \
  --cache-control "max-age=3600"

Step 3: Create CloudFront Distribution

This is where the magic happens. We'll create a CloudFront distribution that serves your S3 content globally.

First, create an Origin Access Control (OAC) - this is the secure way to allow CloudFront to access your S3 bucket:

# Create OAC configuration
OAC_CONFIG=$(aws cloudfront create-origin-access-control \
  --origin-access-control-config \
    Name="${BUCKET_NAME}-oac",\
    Description="OAC for ${BUCKET_NAME}",\
    SigningProtocol=sigv4,\
    SigningBehavior=always,\
    OriginAccessControlOriginType=s3 \
  --query 'OriginAccessControl.Id' \
  --output text)

echo "OAC ID: $OAC_CONFIG"

Create the CloudFront distribution:

# Create distribution configuration
cat > distribution-config.json << EOF
{
  "CallerReference": "$(date +%s)",
  "Comment": "CDN for $BUCKET_NAME",
  "Enabled": true,
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "S3-$BUCKET_NAME",
        "DomainName": "$BUCKET_NAME.s3.${REGION}.amazonaws.com",
        "OriginAccessControlId": "$OAC_CONFIG",
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        }
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-$BUCKET_NAME",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": {
        "Quantity": 2,
        "Items": ["GET", "HEAD"]
      }
    },
    "Compress": true,
    "ForwardedValues": {
      "QueryString": false,
      "Cookies": {
        "Forward": "none"
      }
    },
    "MinTTL": 0,
    "DefaultTTL": 86400,
    "MaxTTL": 31536000
  },
  "DefaultRootObject": "index.html",
  "CustomErrorResponses": {
    "Quantity": 2,
    "Items": [
      {
        "ErrorCode": 403,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 300
      },
      {
        "ErrorCode": 404,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 300
      }
    ]
  },
  "PriceClass": "PriceClass_100"
}
EOF

# Create the distribution
DISTRIBUTION_ID=$(aws cloudfront create-distribution \
  --distribution-config file://distribution-config.json \
  --query 'Distribution.Id' \
  --output text)

echo "Distribution ID: $DISTRIBUTION_ID"

# Get the CloudFront domain name
CLOUDFRONT_DOMAIN=$(aws cloudfront get-distribution \
  --id $DISTRIBUTION_ID \
  --query 'Distribution.DomainName' \
  --output text)

echo "Your CloudFront URL: https://$CLOUDFRONT_DOMAIN"

What's happening here?

  • redirect-to-https: Automatically redirects HTTP to HTTPS
  • Compress: true: Enables gzip/brotli compression
  • DefaultRootObject: Serves index.html for root requests
  • CustomErrorResponses: Enables client-side routing for SPAs
  • PriceClass_100: Uses North America and Europe edge locations (cheapest)

Step 4: Update S3 Bucket Policy

CloudFront needs permission to access your S3 bucket. Get the policy from CloudFront:

# Get the bucket policy statement from CloudFront
aws cloudfront get-distribution-config --id $DISTRIBUTION_ID \
  --query 'DistributionConfig.Origins.Items[0].S3OriginConfig' \
  --output json

Create and apply the bucket policy:

cat > bucket-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::${BUCKET_NAME}/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::$(aws sts get-caller-identity --query Account --output text):distribution/${DISTRIBUTION_ID}"
        }
      }
    }
  ]
}
EOF

aws s3api put-bucket-policy \
  --bucket $BUCKET_NAME \
  --policy file://bucket-policy.json

Security win: Your S3 bucket is now only accessible via CloudFront, not directly. This prevents bandwidth theft and ensures all traffic goes through your CDN.

Step 5: Configure Custom Domain and HTTPS (Optional)

Want to use your own domain instead of the CloudFront URL? Here's how:

Request SSL certificate:

# Replace with your domain
DOMAIN="yourdomain.com"

# Request certificate (must be in us-east-1 for CloudFront)
CERT_ARN=$(aws acm request-certificate \
  --domain-name $DOMAIN \
  --subject-alternative-names "www.$DOMAIN" \
  --validation-method DNS \
  --region us-east-1 \
  --query 'CertificateArn' \
  --output text)

echo "Certificate ARN: $CERT_ARN"

# Get DNS validation records
aws acm describe-certificate \
  --certificate-arn $CERT_ARN \
  --region us-east-1 \
  --query 'Certificate.DomainValidationOptions' \
  --output table

Add the DNS validation records to your domain's DNS settings. Once validated (can take 5-30 minutes), update your CloudFront distribution:

# Update distribution with custom domain
aws cloudfront update-distribution \
  --id $DISTRIBUTION_ID \
  --distribution-config '{
    "Aliases": {
      "Quantity": 2,
      "Items": ["'$DOMAIN'", "www.'$DOMAIN'"]
    },
    "ViewerCertificate": {
      "ACMCertificateArn": "'$CERT_ARN'",
      "SSLSupportMethod": "sni-only",
      "MinimumProtocolVersion": "TLSv1.2_2021"
    }
  }'

Update DNS records:

If using Route 53:

# Get hosted zone ID
HOSTED_ZONE_ID=$(aws route53 list-hosted-zones-by-name \
  --dns-name $DOMAIN \
  --query "HostedZones[0].Id" \
  --output text)

# Create Route 53 record
cat > dns-record.json << EOF
{
  "Changes": [
    {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "$DOMAIN",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "$CLOUDFRONT_DOMAIN",
          "EvaluateTargetHealth": false
        }
      }
    }
  ]
}
EOF

aws route53 change-resource-record-sets \
  --hosted-zone-id $HOSTED_ZONE_ID \
  --change-batch file://dns-record.json

Step 6: Optimize Performance

Enable HTTP/2 and HTTP/3:

CloudFront automatically supports HTTP/2. For HTTP/3 (QUIC):

aws cloudfront update-distribution \
  --id $DISTRIBUTION_ID \
  --distribution-config '{
    "HttpVersion": "http2and3"
  }'

Set up cache invalidation for deployments:

When you update your site, invalidate the cache:

# Invalidate everything (costs $0 for first 1000 paths/month)
aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths "/*"

# Invalidate specific files
aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths "/index.html" "/css/*" "/js/*"

Automated deployment script:

#!/bin/bash
# deploy.sh - Deploy and invalidate cache

set -e

BUCKET_NAME="my-awesome-website"
DISTRIBUTION_ID="YOUR_DISTRIBUTION_ID"

echo "Building website..."
npm run build  # or your build command

echo "Uploading to S3..."
aws s3 sync ./dist s3://$BUCKET_NAME/ --delete

echo "Invalidating CloudFront cache..."
aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths "/*"

echo "✅ Deployment complete!"
echo "🌍 Site will update globally in 1-2 minutes"

Performance Metrics

After setup, you should see:

Before (regular hosting):

  • TTFB (Time to First Byte): 300-800ms
  • Full page load: 2-4 seconds
  • Global latency: varies wildly by location

After (S3 + CloudFront):

  • TTFB: 20-100ms (edge location)
  • Full page load: 0.5-1.5 seconds
  • Global latency: consistent worldwide

Test your site's performance:

Cost Breakdown (2025 Pricing)

S3 Storage:

  • $0.023 per GB/month
  • For 1GB website: $0.023/month

S3 Data Transfer:

  • First 1GB/month: Free
  • Data transfer to CloudFront: Free (zero cost)

CloudFront:

  • First 1TB/month (12 months free tier): $0
  • After free tier: $0.085 per GB
  • 10TB/month: $850 (but you'd be making serious money at that traffic)

Example: Small to Medium Site

  • 500MB website size
  • 50,000 pageviews/month
  • Average 2MB per pageview (after caching)
  • Total: ~100GB transferred

Monthly cost:

  • S3 storage: $0.01
  • S3 requests: $0.02
  • CloudFront (after free tier): $8.50
  • Total: ~$8.53/month for 50K pageviews

Compare that to most hosting providers charging $10-30/month for far worse performance.

Learn more about cloud cost optimization strategies to minimize CDN costs.

Common Issues and Fixes

Problem: "Access Denied" errors Solution: Check bucket policy. Ensure CloudFront OAC has proper permissions.

Problem: Old content still showing after upload Solution: CloudFront is caching. Create cache invalidation.

Problem: Custom domain not working Solution: Verify DNS propagation (can take 24-48 hours), check certificate validation, ensure distribution has correct aliases.

Problem: Slow invalidations Solution: Invalidations take 5-15 minutes. For instant updates, use versioned filenames (style.v123.css) instead of invalidating.

Problem: 403 on SPA routes Solution: Configure custom error responses to redirect 403/404 to index.html (already in our setup).

This setup is perfect for hosting Progressive Web Apps with offline capabilities and app-like experiences.

Best Practices

1. Use versioned filenames: Instead of app.js, use app.v1234.js. Update HTML to reference new version. No cache invalidation needed.

2. Separate cache policies: Long cache for assets (1 year), short cache for HTML (1 hour).

3. Enable compression: Gzip/Brotli compression (already enabled in our setup).

4. Use security headers: Add security headers via Lambda@Edge or CloudFront Functions.

5. Monitor costs: Set up billing alerts in AWS.

6. Use Route 53: Seamless integration with CloudFront for domains.

Next Steps

Add CI/CD: Integrate deployment with GitHub Actions or AWS CodePipeline for automatic deploys on git push.

Implement preview environments: Deploy feature branches to separate CloudFront distributions for testing.

Add Lambda@Edge: Run code at CloudFront edge locations for dynamic behavior (A/B testing, authentication, URL rewrites). Pair your static frontend with a serverless API backend using Lambda.

Set up monitoring: CloudWatch alarms for 4xx/5xx errors, cache hit rate monitoring.

Optimize images: Use CloudFront's automatic image optimization or integrate with services like Cloudinary.

The Bottom Line

You now have a professional static website hosting setup that:

  • Loads in milliseconds globally
  • Scales to millions of visitors automatically
  • Costs pennies for small sites
  • Has enterprise-grade security and reliability
  • Uses HTTPS by default

This is the same setup used by companies serving billions of requests. It's production-ready, battle-tested, and ridiculously fast.

Your users in Nairobi, New York, and New Delhi will all get the same lightning-fast experience. That's the power of CloudFront.

Now go deploy something beautiful.

Sources: