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 returnAccessDenied. - 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:
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 credentialsAWS_CF_DISTRIBUTION_ID— Distribution ID (E...)SUBMODULE_ACCESS_TOKEN— only if your theme is a private submodule
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
- Open AWS Console → S3 → Create bucket.
- Name the bucket (e.g.
www.your-domain.com) and pick a Region — remember this region; it must matchAWS_S3_REGIONin the workflow. - 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:
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}--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
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.com→ DNS validation → add the validation CNAME record → wait for status Issued.
- ACM → Request a public certificate → domain
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:
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.
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:
| Type | Name (label only!) | Value / Data | TTL |
|---|---|---|---|
| CNAME | www | dXXXXXXXX.cloudfront.net | 300 |
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:
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.
CloudFront → Functions → Create function (e.g.
rewrite-index).Paste the code:
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}Publish.
Distribution → Behaviors tab → Default (*) → Edit → Function associations → Viewer request = your function → Save.
Create an Invalidation for
/*so stale error responses aren’t cached.
/css/style.css, /sitemap.xml, /robots.txt) contain a dot → they’re left untouched and served directly.Step 6: Deploy & Verify
- Commit & push the workflow file to the
mainbranch. - Open the Actions tab on GitHub → confirm the job is green (build → sync → invalidate).
- 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).
S3 + CloudFront breakdown (list price)
| Component | Usage | Cost (USD) | Est. (IDR) |
|---|---|---|---|
| S3 Standard storage | 5 GB | ~$0.13 | ~Rp2,000 |
| S3 requests | deploy + minimal | ~$0.02 | ~Rp300 |
| CloudFront data transfer out | 50 GB | ~$6.00 | ~Rp96,000 |
| CloudFront requests (HTTPS) | ~1.2M | ~$1.44 | ~Rp23,000 |
| SSL certificate (ACM) | 1 | $0.00 | Rp0 |
| 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:
| Component | Cost (USD) | Est. (IDR) |
|---|---|---|
| S3 storage + requests (~5 GB) | ~$0.15 | ~Rp2,400 |
| CloudFront (within free tier) | $0.00 | Rp0 |
| 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
| Option | Cost/month | Pros | Cons |
|---|---|---|---|
| S3 + CloudFront | ~Rp2,400–121,000 (often within free tier) | Global CDN, automatic HTTPS, near-zero maintenance, scalable & durable, pay-as-you-go | More technical initial setup (covered by this article); not for dynamic content |
| Shared Hosting | ~Rp15,000–50,000 | Cheap, easy (cPanel), beginner-friendly | Shared 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, flexible | You 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)
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | Job stuck on “Waiting for a runner to pick up this job” for hours | An old runner image (e.g. ubuntu-20.04) was retired by GitHub | Use 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: |
| 3 | Build error: The “_build” front matter key … was removed | Hugo 0.145+ removed the _build key | Rename _build: to build: in front matter |
| 4 | Build sometimes passes, sometimes fails with no code change | hugo-version: 'latest' → the version drifts | Pin hugo-version to your local version |
| 5 | IllegalLocationConstraintException on sync | Workflow region differs from the bucket’s region | Set AWS_REGION to exactly match the bucket’s region |
| 6 | AccessControlListNotSupported / “bucket does not allow ACLs” | The bucket has ACLs disabled (modern default) | Drop --acl public-read; use a bucket policy / OAC |
| 7 | A subdomain doesn’t resolve at all | The DNS Name field was filled with the FQDN → double domain | Put only the label in Name (www, staging) |
| 8 | AccessDenied (XML) on /about, root is fine | OAC + S3 REST origin doesn’t auto-serve index.html | Add 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 .