Setting Up a Hugo Blog on AWS S3 with Custom Domain
Table of Contents
This guide walks through setting up a Hugo blog on AWS S3 with a custom domain — perfect for your personal blog or portfolio.
Overview
Here’s the pipeline we’ll set up:
Markdown → Hugo Build → S3 Static Host → CloudFront CDN → blog.example.com
- Hugo generates static HTML from markdown
- S3 hosts the files as a static website
- CloudFront provides caching, HTTPS, and better performance
- Ionos DNS routes your subdomain to CloudFront
Prerequisites
- Hugo installed
- AWS account with access to S3 and CloudFront
- Domain/subdomain in Ionos (e.g.,
blog.example.com)
Step 1: Build Your Hugo Site
cd /workspace/hugo-blog
hugo --minify
This generates a public/ directory with all static files.
Step 2: Create the S3 Bucket
Using CLI
aws s3api create-bucket \
--bucket blog.example.com \
--region us-east-1
Using AWS Console
- Go to the S3 Console (
https://s3.console.aws.amazon.com/s3/buckets) - Click Create bucket
- Enter your bucket name (e.g.,
blog.example.com) - Choose the us-east-1 (N. Virginia) region
Note: If using CloudFront, the bucket must be in
us-east-1. - Keep all defaults for the remaining steps
- Scroll down and click Create bucket
Step 3: Enable Static Website Hosting
Using CLI
aws s3api put-bucket-website \
--bucket blog.example.com \
--website-configuration '{
"IndexDocument": {"Suffix": "index.html"},
"ErrorDocument": {"Key": "404.html"}
}'
Using AWS Console
- Go to your bucket in the S3 Console
- Click the Properties tab
- Scroll down to Static website hosting
- Click Edit
- Under Hosting type, select Host a static website
- Enter:
- Index document:
index.html - Error document:
404.html
- Index document:
- Click Save changes
Step 4: Set Bucket Policy for Public Access
Using CLI
cat > bucket-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::blog.example.com/*"
}
]
}
EOF
aws s3api put-bucket-policy \
--bucket blog.example.com \
--policy file://bucket-policy.json
Using AWS Console
- In your bucket, go to the Permissions tab
- Scroll to Bucket policy
- Click Edit
- Paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::blog.example.com/*"
}
]
}
- Click Save changes
Step 5: Upload Your Content
Using CLI (Recommended)
aws s3 sync ./public/ s3://blog.example.com/ --delete
The --delete flag ensures deleted files are removed from S3.
Using AWS Console
The S3 console doesn’t support recursive uploads easily. You have two options:
Option A: Use the AWS CLI (recommended) The console method is not practical for a full site with many files.
Option B: Upload manually
- In your bucket, click the Objects tab
- Click Upload
- Drag and drop all files and folders from your
public/directory - Click Upload
Tip: For a full site, the CLI
synccommand is much faster and more reliable than the console.
Step 6 (Optional): Set Up CloudFront
CloudFront provides HTTPS, caching, and faster delivery. This step is optional but recommended.
Using CLI
aws cloudfront create-distribution \
--origin-domain-name blog.example.com.s3-website-us-east-1.amazonaws.com \
--comment "Hugo blog" \
--default-root-object index.html \
--viewer-cert '{"CloudFrontDefaultCertificate": true}' \
--price-class PriceClass_100
Using AWS Console
- Go to the CloudFront Console (
https://console.aws.amazon.com/cloudfront/v3/home) - Click Create distribution
- Under Origin domain, select your S3 bucket from the dropdown
- Scroll down and configure:
- Default root object:
index.html - Viewer protocol policy: Redirect HTTP to HTTPS
- Allowed HTTP methods: GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
- Default root object:
- Keep defaults for the remaining steps
- Click Create distribution
Note on Access
Since CloudFront needs access to your S3 bucket, you should either:
- Keep the public bucket policy from Step 4 (simpler, less secure)
- Set up Origin Access Control (OAC) — makes S3 private and grants CloudFront only the access it needs (more secure, more setup)
If you skip CloudFront, keep the public bucket policy from Step 4.
Step 7: Configure Ionos DNS
Log in to your Ionos Control Center and navigate to DNS → Records.
Add a CNAME Record
| Type | Host | Value | TTL |
|---|---|---|---|
| CNAME | blog | d-xxxxxxxxxx.cloudfront.net (from Step 6) | 600 |
If using S3 directly (no CloudFront): Point to
blog.example.com.s3-website-us-east-1.amazonaws.com
Save and Wait
DNS propagation typically takes 5-30 minutes. You can check with:
nslookup blog.example.com
Step 8: Verify
Visit https://blog.example.com — your Hugo blog should load!
Keeping It Updated
Whenever you write a new post:
cd /workspace/hugo-blog
hugo --minify
aws s3 sync ./public/ s3://blog.example.com/ --delete
To invalidate CloudFront cache after an update:
Using CLI
aws cloudfront create-invalidation \
--distribution-id <YOUR-DISTRIBUTION-ID> \
--paths "/*"
Using AWS Console
- Go to the CloudFront Console
- Select your distribution
- Click the Invalidations tab
- Click Create invalidation
- Enter
/*as the path - Click Create invalidation
Cost Estimate
| Service | Cost |
|---|---|
| S3 (static) | ~$0.023/GB |
| CloudFront | ~$0.085/GB |
| Total (small) | ~$1-2/month |
Troubleshooting
503 Service Unavailable
- Check S3 bucket policy allows public read
- Verify CloudFront origin points to the S3 website endpoint (not the REST endpoint)
404 on Specific Pages
- Hugo generates static HTML — ensure
public/has all files - Check S3 has the exact path (e.g.,
posts/hello/index.html)
DNS Not Resolving
- Check Ionos DNS records are active
- Verify TTL hasn’t expired — try
dig blog.example.com - Ensure CNAME points to the right CloudFront/S3 endpoint
SSL/HTTPS Issues
- If using CloudFront, CloudFront handles HTTPS automatically
- If using S3 directly, you’ll need Route53 + ACM for HTTPS on custom domains