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
  1. Hugo generates static HTML from markdown
  2. S3 hosts the files as a static website
  3. CloudFront provides caching, HTTPS, and better performance
  4. 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

  1. Go to the S3 Console (https://s3.console.aws.amazon.com/s3/buckets)
  2. Click Create bucket
  3. Enter your bucket name (e.g., blog.example.com)
  4. Choose the us-east-1 (N. Virginia) region

    Note: If using CloudFront, the bucket must be in us-east-1.

  5. Keep all defaults for the remaining steps
  6. 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

  1. Go to your bucket in the S3 Console
  2. Click the Properties tab
  3. Scroll down to Static website hosting
  4. Click Edit
  5. Under Hosting type, select Host a static website
  6. Enter:
    • Index document: index.html
    • Error document: 404.html
  7. 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

  1. In your bucket, go to the Permissions tab
  2. Scroll to Bucket policy
  3. Click Edit
  4. Paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::blog.example.com/*"
        }
    ]
}
  1. Click Save changes

Step 5: Upload Your Content

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

  1. In your bucket, click the Objects tab
  2. Click Upload
  3. Drag and drop all files and folders from your public/ directory
  4. Click Upload

Tip: For a full site, the CLI sync command 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

  1. Go to the CloudFront Console (https://console.aws.amazon.com/cloudfront/v3/home)
  2. Click Create distribution
  3. Under Origin domain, select your S3 bucket from the dropdown
  4. 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
  5. Keep defaults for the remaining steps
  6. 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

TypeHostValueTTL
CNAMEblogd-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

  1. Go to the CloudFront Console
  2. Select your distribution
  3. Click the Invalidations tab
  4. Click Create invalidation
  5. Enter /* as the path
  6. Click Create invalidation

Cost Estimate

ServiceCost
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