/user/kayd @ devops :~$ cat aws-s3-hosting-guide.md

The Complete Guide to AWS S3 Static Website Hosting The Complete Guide to AWS S3 Static Website Hosting

QR Code linking to: The Complete Guide to AWS S3 Static Website Hosting
Karandeep Singh
Karandeep Singh
• 12 minutes

Summary

A practical guide to S3 hosting that walks through moving a WordPress documentation site to S3+CloudFront. Covers caching, cost, security hardening, and CI/CD best practices.

Cloud server technology representing scalable web hosting infrastructure

The Expensive WordPress Problem

Picture a documentation website running on WordPress across multiple EC2 instances behind an ALB, with a large catalogue of pages and a substantial monthly AWS bill.

The performance of a setup like this is usually poor:

  • Slow average page loads
  • High server response times
  • Heavy database query overhead on every page
  • Recurring outages from PHP-FPM crashes under load

If the site is entirely static content - no user accounts, no comments, no dynamic functionality - every page hit still requires PHP to query MySQL, render the template, and serve HTML. That is paying a premium to serve static content dynamically.

This article walks through migrating a site like this to S3 + CloudFront hosting, which dramatically reduces hosting costs while delivering much faster average load times.

The Migration Strategy: WordPress to Static S3

The migration plan:

  1. Export WordPress content to static HTML
  2. Set up S3 bucket configured for static website hosting
  3. Configure CloudFront CDN with custom domain and SSL
  4. Implement 301 redirects for changed URLs
  5. Switch DNS from ALB to CloudFront
  6. Decommission EC2 instances

The critical challenge: the page count was large enough that migration needed to be automated and thoroughly tested before cutover.

Phase 1: Static Site Export from WordPress

First challenge: exporting a large WordPress site to static HTML. Off-the-shelf plugins like “Simply Static” often crash partway through the export on large sites.

The solution: custom export script using WordPress CLI and parallel processing:

#!/bin/bash
# export_wordpress.sh - Export WordPress to static HTML

WP_CLI="/usr/local/bin/wp"
OUTPUT_DIR="/var/www/static_export"
BASE_URL="https://docs.example.com"

# Get all published pages and posts
$WP_CLI post list --post_type=page,post --post_status=publish --format=ids | \
tr ' ' '\n' | \
xargs -P 10 -I {} bash -c '
    # Get post slug and content
    SLUG=$('$WP_CLI' post get {} --field=post_name)
    URL=$('$WP_CLI' post url {})

    # Fetch rendered HTML
    curl -s "$URL" > "'$OUTPUT_DIR'/$SLUG.html"

    echo "Exported: $SLUG"
'

This script processes pages in parallel (10 concurrent). On a big site, expect the full export to take a while, and expect to run it more than once before the URL mapping comes out right. Key points:

  • Use xargs -P for parallelization
  • Handle WordPress permalinks correctly
  • Preserve URL structure for SEO

Phase 2: S3 Bucket Configuration

Setting up the S3 bucket required specific configuration for static website hosting:

# Create bucket
aws s3 mb s3://docs-example-com --region us-east-1

# Enable static website hosting
aws s3 website s3://docs-example-com \
    --index-document index.html \
    --error-document 404.html

# Upload content
aws s3 sync /var/www/static_export s3://docs-example-com \
    --delete \
    --cache-control "max-age=31536000, public" \
    --exclude "*.html" \
    --exclude "*.xml"

# HTML files get shorter cache (for updates)
aws s3 sync /var/www/static_export s3://docs-example-com \
    --delete \
    --cache-control "max-age=3600, public" \
    --exclude "*" \
    --include "*.html" \
    --include "*.xml"

Critical mistake to avoid: setting Cache-Control to 1 year on HTML files. If broken links slip through, CloudFront caches the 404 pages for a year, and the resulting wide-scale invalidation racks up real charges.

The correct approach: long cache for static assets (images, CSS, JS), short cache for HTML.

Phase 3: CloudFront Configuration (The Tricky Part)

CloudFront setup commonly hits three major bugs that can take time to debug:

Bug #1: Origin Access Identity Misconfiguration

The obvious first attempt at CloudFront configuration:

# Create CloudFront distribution (WRONG - causes 403 errors)
aws cloudfront create-distribution --origin-domain-name docs-example-com.s3.amazonaws.com

This fails with 403 errors on all requests. The problem: CloudFront tries to access S3 objects directly, but the bucket policy only allows public access via the S3 website endpoint.

The fix: Use S3 website endpoint as origin, not the S3 bucket endpoint:

# Correct origin configuration
aws cloudfront create-distribution \
    --origin-domain-name docs-example-com.s3-website-us-east-1.amazonaws.com \
    --default-root-object index.html

Key difference:

  • S3 bucket endpoint: bucket-name.s3.amazonaws.com (requires OAI/OAC)
  • S3 website endpoint: bucket-name.s3-website-region.amazonaws.com (public access)

Bug #2: Missing Custom Error Responses

After fixing origin, CloudFront serves its default error pages (white text on black background - terrible UX). This needs custom 404 handling:

# Configure custom error responses
aws cloudfront update-distribution --id DISTRIBUTION_ID --distribution-config '{
  "CustomErrorResponses": {
    "Items": [
      {
        "ErrorCode": 404,
        "ResponsePagePath": "/404.html",
        "ResponseCode": "404",
        "ErrorCachingMinTTL": 300
      },
      {
        "ErrorCode": 403,
        "ResponsePagePath": "/404.html",
        "ResponseCode": "404",
        "ErrorCachingMinTTL": 300
      }
    ]
  }
}'

Critical insight: S3 website hosting returns 403 for missing files, not 404. Must map both 403 and 404 to custom error page.

Bug #3: Cache Invalidation Hell

After deploying, you may find a number of broken links. Fixing them and uploading corrected HTML isn’t enough on its own — CloudFront keeps serving the old broken pages.

Problem: CloudFront’s default TTL is 24 hours. The content stays cached with broken links.

The expensive part:

# Invalidate all HTML files (expensive!)
aws cloudfront create-invalidation \
    --distribution-id DISTRIBUTION_ID \
    --paths "/*.html" "/*/index.html"

# Cost: $0.005 per path after first 1000 free per month
# A wide invalidation across many HTML paths can rack up real charges

The correct approach: Use versioned filenames for assets, or use shorter TTL for HTML:

# Set appropriate cache behaviors
aws s3 cp /var/www/static_export s3://docs-example-com \
    --recursive \
    --cache-control "max-age=3600" \
    --exclude "*" \
    --include "*.html"

After these fixes, CloudFront works smoothly, with very low average response times globally.

Phase 4: Custom Domain and SSL Configuration

Configuring custom domain required three components: Route 53, ACM certificate, and CloudFront alias.

SSL Certificate Request (Must be in us-east-1)

Critical mistake to avoid: requesting the ACM certificate in a regional location such as ca-central-1. CloudFront REQUIRES certificates in us-east-1.

# Request certificate in us-east-1 (required for CloudFront)
aws acm request-certificate \
    --domain-name docs.example.com \
    --validation-method DNS \
    --region us-east-1

# Get certificate ARN
CERT_ARN="arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID"

DNS Validation

# Add CNAME records to Route 53 for validation
aws route53 change-resource-record-sets --hosted-zone-id Z1234567890ABC --change-batch '{
  "Changes": [{
    "Action": "CREATE",
    "ResourceRecordSet": {
      "Name": "_validation.docs.example.com",
      "Type": "CNAME",
      "TTL": 300,
      "ResourceRecords": [{"Value": "validation-value-from-acm.acm-validation.aws."}]
    }
  }]
}'

Certificate validation completes quickly once the DNS records propagate.

CloudFront Domain Configuration

# Update CloudFront distribution with custom domain and SSL
aws cloudfront update-distribution --id DISTRIBUTION_ID --distribution-config '{
  "Aliases": {
    "Items": ["docs.example.com"]
  },
  "ViewerCertificate": {
    "ACMCertificateArn": "'$CERT_ARN'",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  }
}'

Route 53 A Record

# Create alias record pointing to CloudFront
aws route53 change-resource-record-sets --hosted-zone-id Z1234567890ABC --change-batch '{
  "Changes": [{
    "Action": "CREATE",
    "ResourceRecordSet": {
      "Name": "docs.example.com",
      "Type": "A",
      "AliasTarget": {
        "HostedZoneId": "Z2FDTNDATAQYW2",
        "DNSName": "d123456789.cloudfront.net",
        "EvaluateTargetHealth": false
      }
    }
  }]
}'

Note: Z2FDTNDATAQYW2 is CloudFront’s hosted zone ID (constant for all CloudFront distributions).

HTTPS works as soon as the DNS change propagates.

What to Expect on Performance

After a migration like this, performance improves dramatically:

Load Time Comparison

Before (WordPress on EC2):

  • Slow average page loads
  • High Time to First Byte (TTFB)
  • Sluggish DOM Content Loaded times
  • Long fully-loaded times

After (S3 + CloudFront):

  • Much faster average page loads
  • Very low TTFB
  • Snappy DOM Content Loaded
  • Quick fully-loaded times

Geographic Performance

Testing from different locations using WebPageTest makes the difference obvious - especially for far-from-origin users.

CloudFront’s edge locations eliminate most of the geographic latency. The biggest gains are for users physically far from the origin region.

Traffic Handling

If you load test both configurations, expect results like:

WordPress Setup:

  • Throughput plateaus at modest sustained request rates before response times degrade
  • CPU pegs under load
  • Requires aggressive CloudWatch alarms

S3 + CloudFront:

  • Sustains much higher request rates with no degradation
  • No scaling concerns
  • CloudFront absorbs load automatically

Under an unexpected traffic surge from a popular link, a WordPress setup would likely buckle, while S3 + CloudFront absorbs it without trouble.

Cost Breakdown

Where the money goes in each setup:

Before: WordPress on EC2

The bulk of the bill is driven by:

  • Multiple long-running EC2 instances
  • Provisioned EBS volumes
  • An Application Load Balancer
  • A managed RDS database
  • Data transfer out
  • Automated EBS snapshot backups

This adds up to a substantial monthly spend just to keep static-feeling content online.

After: S3 + CloudFront

The replacement bill collapses to a handful of small line items:

  • S3 storage for the site contents
  • S3 GET requests
  • CloudFront data transfer
  • CloudFront requests
  • Route 53 hosted zone
  • ACM certificate (free)

Total monthly cost ends up at a fraction of the original.

The result is a large monthly cost reduction.

This doesn’t include the eliminated operational costs:

  • No more EC2 patching
  • No more WordPress/PHP updates
  • No more database backups to monitor
  • No more late-night “site down” alerts

Savings like these free up engineer time for feature development instead of infrastructure maintenance.

Security Hardening: Production Best Practices

After initial deployment, it’s worth auditing the setup, drawing on the kind of controls covered in this AWS security features guide. Three common issues:

Issue #1: S3 Bucket Public Access

A publicly accessible S3 bucket — while necessary for S3 website hosting — is a red flag in security scans.

The solution: Use CloudFront with Origin Access Control (OAC) and block public S3 access:

# Block public access to S3 bucket
aws s3api put-public-access-block \
    --bucket docs-example-com \
    --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# Create Origin Access Control
aws cloudfront create-origin-access-control \
    --origin-access-control-config '{
        "Name": "docs-oac",
        "SigningProtocol": "sigv4",
        "SigningBehavior": "always",
        "OriginAccessControlOriginType": "s3"
    }'

# Update bucket policy to only allow CloudFront
aws s3api put-bucket-policy --bucket docs-example-com --policy '{
    "Version": "2012-10-17",
    "Statement": [{
        "Sid": "AllowCloudFrontServicePrincipal",
        "Effect": "Allow",
        "Principal": {"Service": "cloudfront.amazonaws.com"},
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::docs-example-com/*",
        "Condition": {
            "StringEquals": {
                "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
            }
        }
    }]
}'

Note: This requires using S3 bucket origin (not S3 website endpoint). Change the CloudFront origin configuration accordingly.

Issue #2: Missing Security Headers

A fresh deployment has no security headers. Add them with a CloudFront response headers policy:

aws cloudfront create-response-headers-policy \
    --response-headers-policy-config '{
        "Name": "security-headers-policy",
        "SecurityHeadersConfig": {
            "StrictTransportSecurity": {
                "Override": true,
                "AccessControlMaxAgeSec": 31536000,
                "IncludeSubdomains": true
            },
            "ContentTypeOptions": {"Override": true},
            "FrameOptions": {
                "Override": true,
                "FrameOption": "DENY"
            },
            "XSSProtection": {
                "Override": true,
                "Protection": true,
                "ModeBlock": true
            },
            "ReferrerPolicy": {
                "Override": true,
                "ReferrerPolicy": "strict-origin-when-cross-origin"
            }
        }
    }'

This resolves security scanner complaints and meaningfully improves the security scan score.

Issue #3: Access Logging

For compliance, enable S3 and CloudFront access logging:

# Create logging bucket
aws s3 mb s3://docs-example-com-logs

# Enable S3 access logging
aws s3api put-bucket-logging --bucket docs-example-com \
    --bucket-logging-status '{
        "LoggingEnabled": {
            "TargetBucket": "docs-example-com-logs",
            "TargetPrefix": "s3-access-logs/"
        }
    }'

# Enable CloudFront logging
aws cloudfront update-distribution --id DISTRIBUTION_ID --distribution-config '{
    "Logging": {
        "Enabled": true,
        "IncludeCookies": false,
        "Bucket": "docs-example-com-logs.s3.amazonaws.com",
        "Prefix": "cloudfront-logs/"
    }
}'

Access logs are valuable when investigating traffic spikes — for example, distinguishing a bot from real users so it can be blocked via WAF.

Deployment Automation with GitHub Actions

Manual deployment works for the initial migration, but ongoing updates need CI/CD. If you prefer running the same pipeline inside AWS, a CodeBuild buildspec walkthrough covers the equivalent build phases. A GitHub Actions pipeline:

# .github/workflows/deploy.yml
name: Deploy to S3

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Sync to S3
        run: |
          aws s3 sync ./content s3://docs-example-com \
            --delete \
            --cache-control "max-age=3600" \
            --exclude "*.jpg" \
            --exclude "*.png" \
            --exclude "*.css" \
            --exclude "*.js"

          # Static assets get longer cache
          aws s3 sync ./content s3://docs-example-com \
            --cache-control "max-age=31536000, immutable" \
            --exclude "*" \
            --include "*.jpg" \
            --include "*.png" \
            --include "*.css" \
            --include "*.js"

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*.html" "/sitemap.xml" "/robots.txt"

        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

Key optimizations:

  • Only invalidate HTML/sitemap/robots.txt (not images/CSS) to minimize costs
  • Use --delete flag to remove old files
  • Separate cache-control for different file types

This pipeline deploys very quickly, compared to a typical WordPress deployment process.

Monitoring and Alerting

Set up CloudWatch alarms to monitor:

# Alert on 5xx error rate > 1%
aws cloudwatch put-metric-alarm \
    --alarm-name "docs-high-error-rate" \
    --alarm-description "CloudFront 5xx error rate above 1%" \
    --metric-name 5xxErrorRate \
    --namespace AWS/CloudFront \
    --statistic Average \
    --period 300 \
    --evaluation-periods 2 \
    --threshold 1.0 \
    --comparison-operator GreaterThanThreshold \
    --dimensions Name=DistributionId,Value=$DISTRIBUTION_ID

# Alert on sudden traffic spike (potential DDoS)
aws cloudwatch put-metric-alarm \
    --alarm-name "docs-traffic-spike" \
    --alarm-description "Request rate 10x above baseline" \
    --metric-name Requests \
    --namespace AWS/CloudFront \
    --statistic Sum \
    --period 300 \
    --evaluation-periods 1 \
    --threshold 100000 \
    --comparison-operator GreaterThanThreshold

With this architecture in place, you can expect:

0
  • Effectively no 5xx error alerts — the architecture is highly available
  • Occasional traffic spike alerts, typically legitimate traffic rather than attacks
  • Monthly cost stays comfortably low

Key Takeaways

What You Gain

  1. Performance: Much faster load times globally
  2. Cost: A large reduction in monthly hosting spend
  3. Zero maintenance burden: No more security patches, no more late-night server crashes
  4. Effectively unlimited scalability: Traffic spikes stop being an event
  5. Improved security posture: Fewer moving parts to patch and scan

What Goes Wrong (And How to Fix It)

  1. Initial CloudFront misconfiguration: The wrong origin endpoint means 403s everywhere, and fixing broken content afterwards can cost real money in invalidation charges. Use the correct origin endpoint from the start.
  2. Broken links after migration: A batch of pages may have incorrect relative links — fix them with a find/replace script before redeployment.
  3. Overly aggressive caching: HTML cached for a day means stale content after every fix. Keep HTML TTLs short.
  4. Missing security headers: Scanners will flag this. Fix with a CloudFront response headers policy.
  5. No access logging: Turn it on from day one — audits will ask for it, and it is invaluable for debugging.

Key Recommendations

For anyone considering S3 hosting migration:

  1. Use S3 website endpoint OR bucket origin with OAC, not both: They have different behaviors. Choose one approach and stick with it.

  2. Get caching right from the start: Long cache for assets, short cache for HTML. Wrong caching is expensive to fix.

  3. Enable access logging immediately: Required for security compliance and invaluable for debugging.

  4. Test thoroughly before cutover: Run the new stack on a test subdomain for a meaningful soak period before switching production DNS.

  5. Have rollback plan: Keep the old WordPress environment running for a buffer window after cutover in case of issues.

  6. Monitor costs daily initially: Watch your AWS bill closely for the first while to ensure no surprises.

    1

Is It Worth the Migration Effort?

Migration phases (high level):

  • Planning and testing
  • Export and data cleanup
  • S3 and CloudFront configuration
  • Security hardening
  • DNS cutover and monitoring

Typical ROI of this architecture:

  • Significant cost savings versus the legacy stack
  • Eliminated operational burden (no patching, no incidents)
  • Much faster load times
  • Strong availability

A migration like this pays for itself quickly. Everything after that is pure savings and improved user experience.

For anyone running static/semi-static sites on traditional hosting: S3 + CloudFront is worth serious consideration. The cost savings alone justify the migration effort, and the performance improvements are a massive bonus. If your source content lives in a static site generator, this Hugo deploy via CodeBuild guide walks through wiring the same S3+CloudFront target into an automated build.

2

References and Further Reading

Question

Have you migrated from traditional hosting to S3? What challenges did you encounter?

Similar Articles

More from cloud

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.