Build a multi-container app with Docker Compose, then build images with Docker Bake and push them to …
The Complete Guide to AWS S3 Static Website Hosting The Complete Guide to AWS S3 Static Website Hosting

Summary

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 was terrible:
- Slow average page loads
- High server response times
- Heavy database query overhead on every page
- Recurring outages from PHP-FPM crashes under load
The site was entirely static content - no user accounts, no comments, no dynamic functionality. Every page hit required PHP to query MySQL, render the template, and serve HTML. The site was 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.
Expand your knowledge with Go + Nginx: Deploy a Go API Behind a Reverse Proxy
The Migration Strategy: WordPress to Static S3
The migration plan:
- Export WordPress content to static HTML
- Set up S3 bucket configured for static website hosting
- Configure CloudFront CDN with custom domain and SSL
- Implement 301 redirects for changed URLs
- Switch DNS from ALB to CloudFront
- Decommission EC2 instances
The critical challenge: the page count was large enough that migration needed to be automated and thoroughly tested before cutover.
Deepen your understanding in Deploy a Hugo Site to S3 with CodeBuild
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 processed pages in parallel (10 concurrent), and the full export took several hours. Key lessons:
Explore this further in Deploy a Hugo Site to S3 with CodeBuild
- Use
xargs -Pfor parallelization - Handle WordPress permalinks correctly
- Preserve URL structure for SEO
- Export took multiple attempts before getting URL mapping correct
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.
Discover related concepts in Deploy a Hugo Site to S3 with CodeBuild
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
First attempt at CloudFront configuration:
# Create CloudFront distribution (WRONG - caused 403 errors)
aws cloudfront create-distribution --origin-domain-name docs-example-com.s3.amazonaws.com
This failed with 403 errors on all requests. The problem: CloudFront was trying to access S3 objects directly, but the bucket policy only allowed public access via 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.
Cost of lesson learned:
# 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 worked smoothly, with very low average response times globally.
Uncover more details in The DevOps Stack I'd Pick If I Started Over in 2026
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 completed quickly once the DNS records propagated.
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).
DNS propagation finished quickly. HTTPS worked immediately after DNS updated.
Journey deeper into this topic with Create Custom AMI of Jenkins | DevOps
Production Performance Metrics
After migration, 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 showed dramatic improvements everywhere - especially for far-from-origin users.
CloudFront’s edge locations eliminated most of the geographic latency. The biggest gains were for users physically far from the original origin region.
Traffic Handling
Stress tested both configurations:
WordPress Setup:
- Throughput plateaued at modest sustained request rates before response times degraded
- CPU pegged under load
- Required aggressive CloudWatch alarms
S3 + CloudFront:
- Sustained much higher request rates with no degradation
- No scaling concerns
- CloudFront absorbed load automatically
Under an unexpected traffic surge from a popular link, a WordPress setup would likely buckle, while S3 + CloudFront absorbs it without trouble.
Enrich your learning with Sed Cheat Sheet: 30 One-Liners from Real Production Logs
Real Cost Breakdown
Where the money used to go (and where it now goes):
Before: WordPress on EC2
The bulk of the bill was 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 added up to a substantial monthly spend just to keep static-feeling content online.
After: S3 + CloudFront
The replacement bill collapsed 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 dropped to a fraction of the original.
The result was 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
The annualized savings were significant.
Savings like these free up engineer time for feature development instead of infrastructure maintenance.
Gain comprehensive insights from Sed Cheat Sheet: 30 One-Liners from Real Production Logs
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
Initial deployment had no security headers. Added using 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.
Master this concept through AWS Security Audit: From AWS CLI to a Go Security Scanner
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
--deleteflag to remove old files - Separate cache-control for different file types
This pipeline deploys very quickly, compared to the much slower WordPress deployment process it replaced.
Delve into specifics at Deployment Automation: From SSH Scripts to a Go Deploy Tool
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:
Deepen your understanding in Deploy a Hugo Site to S3 with CodeBuild
- 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 Went Right
- Performance improvement exceeded expectations: Much faster load times globally
- Cost savings were significant: A large reduction in monthly hosting spend
- Zero maintenance burden: No more security patches, no more late-night server crashes
- Effectively unlimited scalability: Handled traffic spikes with zero issues
- Improved security posture: Security scan score improved meaningfully
What Goes Wrong (And How to Fix It)
- Initial CloudFront misconfiguration: Cost real money in invalidation charges. Fixed by using correct origin endpoint.
- Broken links after migration: A batch of pages may have incorrect relative links — fix them with a find/replace script before redeployment.
- Overly aggressive caching: HTML cached for a day caused stale content. Reduced TTL substantially.
- Missing security headers: Security team flagged. Fixed with CloudFront response headers policy.
- No access logging initially: Added after security audit required it for compliance.
Key Recommendations
For anyone considering S3 hosting migration:
Use S3 website endpoint OR bucket origin with OAC, not both: They have different behaviors. Choose one approach and stick with it.
Get caching right from the start: Long cache for assets, short cache for HTML. Wrong caching is expensive to fix.
Enable access logging immediately: Required for security compliance and invaluable for debugging.
Test thoroughly before cutover: Run the new stack on a test subdomain for a meaningful soak period before switching production DNS.
Have rollback plan: Keep the old WordPress environment running for a buffer window after cutover in case of issues.
Monitor costs daily initially: Watch your AWS bill closely for the first while to ensure no surprises.
1Deepen your understanding in Deploy a Hugo Site to S3 with CodeBuild
Final Results: Worth Every Hour of Migration Effort
Migration timeline (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 since cutover
The migration paid for itself quickly. Everything after that was 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.
Deepen your understanding in Deploy a Hugo Site to S3 with CodeBuild
References and Further Reading
- AWS S3 Static Website Hosting Documentation
- CloudFront Developer Guide
- AWS Well-Architected Framework
- CloudFront Origin Access Control
Have you migrated from traditional hosting to S3? What challenges did you encounter?
Similar Articles
Related Content
More from cloud
Set up a Kubernetes cluster on AWS EKS with eksctl: prerequisites, one-command cluster creation, …
Kubernetes CrashLoopBackOff explained: a workflow to diagnose it and fix the six most common causes, …
You Might Also Like
The DevOps stack I would pick if I started over today, after years of production and painful …
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.
Question 1 of 5
Quiz Complete!
Your score: 0 out of 5
Loading next question...
Contents
- The Expensive WordPress Problem
- The Migration Strategy: WordPress to Static S3
- Phase 1: Static Site Export from WordPress
- Phase 2: S3 Bucket Configuration
- Phase 3: CloudFront Configuration (The Tricky Part)
- Phase 4: Custom Domain and SSL Configuration
- Production Performance Metrics
- Real Cost Breakdown
- Security Hardening: Production Best Practices
- Deployment Automation with GitHub Actions
- Monitoring and Alerting
- Key Takeaways
- Final Results: Worth Every Hour of Migration Effort
- References and Further Reading

