From Cloudflare Pages to AWS: Hosting My Portfolio on S3 + CloudFront
My portfolio site has been running on Cloudflare Pages for…well at least a day! It worked fine - fast, free, and simple to deploy. So why move it?
The honest answer: I wanted to build something I could talk about in a cloud consulting context. Cloudflare Pages is a great product, but “I dragged my repo in and it deployed” isn’t much of an architecture story. S3 + CloudFront is.
The Architecture
The setup is straightforward but covers a solid range of AWS services:
- S3 stores the static Hugo build output — private bucket, no public access
- CloudFront serves the site globally from AWS edge locations, with HTTPS
- ACM (AWS Certificate Manager) handles the SSL certificate — free when used with CloudFront
- IAM provides scoped credentials for the deployment pipeline
- GitHub Actions builds the site with Hugo and syncs it to S3 on every push, then invalidates the CloudFront cache automatically
The key design decision is using Origin Access Control (OAC) — the current AWS best practice — to lock the S3 bucket so only the specific CloudFront distribution can read from it. Nobody can hit the S3 bucket directly. All traffic goes through CloudFront.
Why Not Just Stay on Cloudflare?
No criticism of Cloudflare - it’s genuinely excellent infrastructure. But there were a couple of practical reasons to move:
First, I have AWS credits sitting in my account. Running the site on S3 + CloudFront at this traffic level costs almost nothing, well within the free tier, so the credits cover it comfortably.
Second, and more importantly, this maps directly to AWS Solutions Architect Associate exam topics. S3, CloudFront, ACM, IAM roles, cache invalidation, I built and configured all of these hands-on rather than just reading about them. That’s a different kind of preparation.
The Deployment Pipeline
The GitHub Actions workflow does four things on every push to master:
- Checks out the repo including Hugo theme submodules
- Builds the site with
hugo --minify - Syncs to S3 in two passes, long cache headers for static assets (CSS, JS, images), no-cache for HTML files so visitors always get the latest content
- Creates a CloudFront cache invalidation so the CDN serves fresh content immediately
The two-pass S3 sync is worth explaining. Static assets like images and stylesheets rarely change, so they can be cached aggressively at the edge up to a year. But HTML files change on every deploy, so they need no-cache headers. Treating them the same way would mean either stale pages or unnecessarily slow asset delivery.
- name: Sync static assets (long cache)
run: |
aws s3 sync public/ s3://${{ secrets.AWS_S3_BUCKET }} \
--delete \
--cache-control "max-age=31536000,public" \
--exclude "*.html" \
--exclude "sitemap.xml" \
--exclude "index.xml"
- name: Sync HTML (no cache)
run: |
aws s3 sync public/ s3://${{ secrets.AWS_S3_BUCKET }} \
--delete \
--cache-control "no-cache,no-store,must-revalidate" \
--include "*.html" \
--include "sitemap.xml" \
--include "index.xml"
DNS and SSL
The domain stays on Cloudflare DNS, there’s no reason to move it. The www CNAME points at the CloudFront distribution URL with Cloudflare proxying enabled (orange cloud), and SSL/TLS set to Full (Strict). CloudFront handles HTTPS termination via the ACM certificate, and Cloudflare proxies in front of it.
Certificate validation used DNS validation via ACM, add two CNAME records to Cloudflare, wait a few minutes, and the certificate is issued automatically. No manual renewal ever needed.
One gotcha worth knowing about: the root domain. DNS specification doesn’t allow a CNAME record at the apex domain (anthonyapierre.com) when other record types like MX are also present. Those are the records used so I can have a named domain email address. This is a long-standing DNS limitation that catches a lot of people out. Cloudflare has a workaround called CNAME flattening, but it isn’t always reliable with third-party destinations like CloudFront.
The clean solution is to make www the canonical address and redirect the root to it using a Cloudflare Redirect Rule:
- Request URL:
http://anthonyapierre.com/* - Target URL:
https://www.anthonyapierre.com/${1} - Status code:
301
This is actually standard practice in production — most large sites treat www as canonical and redirect the bare domain to it rather than the other way around.
What This Demonstrates
Here’s what the architecture covers:
- S3 bucket configuration with public access blocked and OAC-based permissions
- CloudFront distribution with custom domain, ACM certificate, and cache behaviours
- IAM user with least-privilege policy scoped to S3 and CloudFront only
- Automated CI/CD pipeline with environment secrets management
- Cache strategy balancing performance and content freshness
It’s a static site, but the underlying architecture is the same pattern used to serve content at serious scale. The site now has a genuine 99.99% SLA backed by AWS global infrastructure — not because I needed that reliability for a portfolio, but because understanding how to build it is the point.
This site is built with Hugo using the Toha theme and deployed automatically via GitHub Actions on every commit.