Skip to content
Santekno.com | Tech Tutorials and Trends
EN
📖 0%

How to Deploy Hugo to AWS S3 + CloudFront with GitHub Actions and a Custom Domain

· 10 min read ·Ihsan Arif

A complete, step-by-step guide to automatically deploy your Hugo site to AWS S3 + CloudFront using GitHub Actions, all the way to a custom domain over HTTPS. Every step here is battle-tested in the real world — including a troubleshooting section for all the errors you’ll likely hit.

If you build your site with Hugo , you need hosting that’s fast, cheap, and secure. The combo of S3 (storage) + CloudFront (global CDN) is one of the best: low cost, high performance, and automatic HTTPS. In this tutorial you’ll learn how to:

  • Build a GitHub Actions pipeline that builds Hugo and deploys to S3 on every push.
  • Set up an S3 bucket correctly (including the region and ACL gotchas).
  • Create a CloudFront distribution with OAC + an ACM certificate (HTTPS).
  • Point a custom domain at CloudFront via DNS.
  • Fix Hugo pretty URLs so /about/ doesn’t return AccessDenied.
  • Troubleshoot 8 real errors that commonly trip people up.

Let’s dive in.


Why S3 + CloudFront?

For a static site like Hugo, the S3 + CloudFront combo is hard to beat:

  • Cheap & pay-as-you-go — there’s no server running 24/7 that you pay for. A small site can be almost free.
  • Fast globally (CDN) — CloudFront serves content from the edge location nearest your visitor, so TTFB stays low wherever they are.
  • Free, automatic HTTPS — certificates from AWS Certificate Manager (ACM) are free and auto-renew.
  • Scalable & durable — S3 offers 99.999999999% (11 nines) durability and absorbs traffic spikes without you touching capacity.
  • Low maintenance — no OS, web server, or security patches to manage. Nothing goes “down” because of a bad Nginx config.

In short: for a static site, you pay for storage + bandwidth only, not for renting a machine you have to maintain.


Prerequisites

Before you start, make sure you have:

  • An existing Hugo site in a GitHub repository.
  • An AWS account (S3, CloudFront, ACM, IAM).
  • A domain you control (you can edit its DNS).
  • Your local Hugo version — check with hugo version (we’ll pin it in CI).

Step 1: GitHub Actions Workflow (Build & Deploy)

Create .github/workflows/deploy.yml in your repository:

yaml
 1name: Deploy Hugo to S3 + CloudFront
 2on:
 3  push:
 4    branches:
 5      - main
 6jobs:
 7  deploy:
 8    runs-on: ubuntu-latest
 9    steps:
10      - name: Checkout (with submodules)
11        uses: actions/checkout@v2
12        with:
13          submodules: recursive
14          token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }}   # only if your theme is a private submodule
15
16      - name: Setup Hugo
17        uses: peaceiris/actions-hugo@v2
18        with:
19          hugo-version: '0.145.0'   # pin to YOUR local version (run: hugo version)
20          extended: true
21
22      - name: Build
23        run: hugo --gc --minify
24
25      - name: Sync to S3
26        uses: jakejarvis/s3-sync-action@master
27        with:
28          args: --follow-symlinks --delete
29        env:
30          SOURCE_DIR: 'public'
31          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
32          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
33          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
34          AWS_REGION: ${{ secrets.AWS_S3_REGION }}   # MUST equal the bucket's region
35
36      - name: Invalidate CloudFront
37        uses: chetan/invalidate-cloudfront-action@master
38        env:
39          DISTRIBUTION: ${{ secrets.AWS_CF_DISTRIBUTION_ID }}   # the E... ID, not the d....cloudfront.net domain
40          PATHS: '/*'
41          AWS_REGION: ${{ secrets.AWS_S3_REGION }}
42          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
43          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Then add the GitHub Secrets (Settings → Secrets and variables → Actions):

  • AWS_S3_BUCKET — bucket name (e.g. www.your-domain.com)
  • AWS_S3_REGION — bucket region (e.g. ap-southeast-1)
  • AWS_ACCESS_KEY_ID & AWS_SECRET_ACCESS_KEY — IAM credentials
  • AWS_CF_DISTRIBUTION_ID — Distribution ID (E...)
  • SUBMODULE_ACCESS_TOKEN — only if your theme is a private submodule
Danger
Important about the Hugo version: don’t use hugo-version: 'latest'. A new Hugo release can change the rules and break your build out of nowhere. Pin it to the same version as your local machine for deterministic builds.

Step 2: Create the S3 Bucket

  1. Open AWS Console → S3 → Create bucket.
  2. Name the bucket (e.g. www.your-domain.com) and pick a Region — remember this region; it must match AWS_S3_REGION in the workflow.
  3. For access, there are two approaches:

Option A — Private bucket + CloudFront OAC (recommended). The bucket stays closed; only CloudFront can read it. More secure. You’ll set the bucket policy after creating CloudFront (see Step 3).

Option B — Public-read bucket via bucket policy. Simpler but the bucket is open to the public. Turn off “Block all public access”, then attach:

json
 1{
 2  "Version": "2012-10-17",
 3  "Statement": [
 4    {
 5      "Sid": "PublicReadGetObject",
 6      "Effect": "Allow",
 7      "Principal": "*",
 8      "Action": "s3:GetObject",
 9      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
10    }
11  ]
12}
Danger
ACL note: modern S3 buckets (created since 2023) have ACLs disabled by default (“Bucket owner enforced”). So don’t use --acl public-read when syncing (you’ll get AccessControlListNotSupported). Grant read access via a bucket policy or OAC, not ACLs. The workflow in Step 1 is already correct — no --acl.

Step 3: Create the CloudFront Distribution

  1. Request a TLS certificate in ACM — region us-east-1 (N. Virginia). CloudFront only accepts certificates from this region, regardless of your bucket’s region.

    • ACM → Request a public certificate → domain www.your-domain.comDNS validation → add the validation CNAME record → wait for status Issued.
  2. CloudFront → Create distribution.

    • Origin domain: select your S3 bucket.
    • Origin access: choose Origin access control (OAC) → create a new OAC. CloudFront will show a bucket policy to paste into your bucket:
    json
     1{
     2  "Version": "2012-10-17",
     3  "Statement": [
     4    {
     5      "Sid": "AllowCloudFrontServicePrincipal",
     6      "Effect": "Allow",
     7      "Principal": { "Service": "cloudfront.amazonaws.com" },
     8      "Action": "s3:GetObject",
     9      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*",
    10      "Condition": {
    11        "ArnLike": {
    12          "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
    13        }
    14      }
    15    }
    16  ]
    17}
    • Viewer protocol policy: Redirect HTTP to HTTPS.
    • Alternate domain name (CNAME): www.your-domain.com.
    • Custom SSL certificate: pick the ACM certificate from step 1.
    • Default root object: index.html.
  3. Create → wait for status Deployed. Note the Distribution domain name (dXXXX.cloudfront.net) and the Distribution ID (E...).


Step 4: Point Your Custom Domain (DNS)

At your DNS provider, add a CNAME record:

TypeName (label only!)Value / DataTTL
CNAMEwwwdXXXXXXXX.cloudfront.net300
Danger
⚠️ The most common pitfall: the Name field takes the subdomain label only (www), not www.your-domain.com. Nearly every DNS panel appends the zone name automatically; if you type the full name, the record becomes www.your-domain.com.your-domain.com and won’t resolve.

Verify after a few minutes:

bash
1# Does DNS point to CloudFront?
2dig +short www.your-domain.com          # -> dXXXXXXXX.cloudfront.net.
3
4# Served over HTTPS, homepage + a deep link?
5curl -I https://www.your-domain.com/
6curl -I https://www.your-domain.com/about/

Step 5: Fix Pretty URLs (CloudFront Function)

Hugo uses pretty URLs like /about/, which are really the file /about/index.html. With OAC + an S3 REST origin, CloudFront asks S3 for an object named about — that object doesn’t exist, and because the bucket is private, S3 returns AccessDenied (instead of serving index.html). The result: every page except the root errors out.

The fix: a CloudFront Function that appends index.html.

  1. CloudFront → FunctionsCreate function (e.g. rewrite-index).

  2. Paste the code:

    javascript
     1function handler(event) {
     2    var request = event.request;
     3    var uri = request.uri;
     4
     5    if (uri.endsWith('/')) {
     6        request.uri += 'index.html';            // /about/  -> /about/index.html
     7    } else if (!uri.includes('.')) {
     8        request.uri += '/index.html';           // /about   -> /about/index.html
     9    }
    10
    11    return request;
    12}
  3. Publish.

  4. Distribution → Behaviors tab → Default (*)EditFunction associationsViewer request = your function → Save.

  5. Create an Invalidation for /* so stale error responses aren’t cached.

Danger
Files with an extension (/css/style.css, /sitemap.xml, /robots.txt) contain a dot → they’re left untouched and served directly.

Step 6: Deploy & Verify

  1. Commit & push the workflow file to the main branch.
  2. Open the Actions tab on GitHub → confirm the job is green (build → sync → invalidate).
  3. Check the result:
    • https://www.your-domain.com/ → homepage loads.
    • https://www.your-domain.com/about/ → deep page loads (thanks to the CloudFront Function).

Done — every push to main now deploys your site automatically. 🎉


Cost Estimate & Comparison vs Hosting/VPS

Let’s price a medium blog: ~5 GB storage, ~100,000 visits/month, ~50 GB transfer/month. Prices reference the ap-southeast-1 (Singapore) region, at ~Rp16,000/USD (mid-2026 approximation).

Danger
The figures below are estimates and can vary by region, traffic pattern, and AWS pricing changes. Always check the AWS pricing calculator for current numbers.

S3 + CloudFront breakdown (list price)

ComponentUsageCost (USD)Est. (IDR)
S3 Standard storage5 GB~$0.13~Rp2,000
S3 requestsdeploy + minimal~$0.02~Rp300
CloudFront data transfer out50 GB~$6.00~Rp96,000
CloudFront requests (HTTPS)~1.2M~$1.44~Rp23,000
SSL certificate (ACM)1$0.00Rp0
Total~$7.6/month~Rp121,000/month

With the CloudFront Free Tier (always free)

CloudFront has an always-free tier: 1 TB data transfer out + 10 million requests per month, free forever. Since our medium blog (50 GB + 1.2M requests) is well under those limits, the CloudFront cost becomes $0. Only S3 storage remains:

ComponentCost (USD)Est. (IDR)
S3 storage + requests (~5 GB)~$0.15~Rp2,400
CloudFront (within free tier)$0.00Rp0
Realistic total~$0.15/month~Rp2,400/month

So a small-to-medium blog is often nearly free on S3 + CloudFront. 🎉

Comparison vs Shared Hosting & VPS

OptionCost/monthProsCons
S3 + CloudFront~Rp2,400–121,000 (often within free tier)Global CDN, automatic HTTPS, near-zero maintenance, scalable & durable, pay-as-you-goMore technical initial setup (covered by this article); not for dynamic content
Shared Hosting~Rp15,000–50,000Cheap, easy (cPanel), beginner-friendlyShared resources, single-location server (slow globally), PHP/MySQL features wasted on a static site, can lag under load
VPS~Rp80,000–150,000+Full control, flexibleYou manage the OS, web server, security patches, SSL renewal yourself; no built-in global CDN; ongoing maintenance

Cost takeaway: for a static site with low-to-medium traffic, S3 + CloudFront is usually both the cheapest and the fastest (often free under the free tier), with no server maintenance. Shared hosting wins on initial simplicity; a VPS only makes sense if you need a dynamic backend (database, server-side rendering).


Troubleshooting (real errors + fixes)

#SymptomCauseFix
1Job stuck on “Waiting for a runner to pick up this job” for hoursAn old runner image (e.g. ubuntu-20.04) was retired by GitHubUse runs-on: ubuntu-latest
2“Unrecognized named-value: ‘secrets’” in a step if:The secrets context isn’t allowed directly in a step if:Map the secret to job-level env, then test env.X in if:
3Build error: The “_build” front matter key … was removedHugo 0.145+ removed the _build keyRename _build: to build: in front matter
4Build sometimes passes, sometimes fails with no code changehugo-version: 'latest' → the version driftsPin hugo-version to your local version
5IllegalLocationConstraintException on syncWorkflow region differs from the bucket’s regionSet AWS_REGION to exactly match the bucket’s region
6AccessControlListNotSupported / “bucket does not allow ACLs”The bucket has ACLs disabled (modern default)Drop --acl public-read; use a bucket policy / OAC
7A subdomain doesn’t resolve at allThe DNS Name field was filled with the FQDN → double domainPut only the label in Name (www, staging)
8AccessDenied (XML) on /about, root is fineOAC + S3 REST origin doesn’t auto-serve index.htmlAdd a CloudFront Function (Step 5)

(Optional) A Staging Environment

If you want a separate preview environment (e.g. staging.your-domain.com), create its own bucket + CloudFront distribution, with a pipeline triggered from a staging branch. Also add noindex + a robots.txt disallow so Google won’t index it, and make sure it’s fully isolated from production (separate bucket/distribution). This is exactly what we did for Santekno’s staging.


Conclusion

You now have an end-to-end Hugo deploy pipeline: GitHub Actions → S3 → CloudFront → custom domain over HTTPS, plus a CloudFront Function for pretty URLs and a checklist of common error fixes. This approach is fast, cheap, and secure for any static site.

Want to hide the default CloudFront URL and force only your custom domain? Read next: How to Hide CloudFront URL and Use a Custom Domain Securely .

IH
Ihsan Arif
Backend engineer & penulis di Santekno. Aktif menulis tentang Go, Laravel, dan arsitektur backend modern.
Follow

💬 Comments