Series: How I Used Claude Code to Slash My Cloud Bill by 63%
- Part 1: The $198/Month Wake-Up Call
- Part 2: Migrating 7 Static Sites to Cloudflare Pages (you are here)
- Part 3: Killing the Load Balancer with Cloudflare Tunnel
- Part 4: Building a Home Router with a Raspberry Pi 5
- Part 5: Connecting Everything with Tailscale
- Part 6: The Final Architecture - $74/mo and Zero Regrets
The Problem: 5 Hops to Serve Static HTML
As I covered in Part 1, my static websites were going through an absurd pipeline:
Browser → Cloudflare CDN → DO App Platform (reverse proxy)
→ K8s Load Balancer → reverse-proxy StatefulSet (3 replicas)
→ nginx StatefulSet (2 replicas + git-sync sidecars)
→ Static HTML from git repo
All to serve pre-generated Hugo HTML. The nginx StatefulSet used git-sync sidecar containers to continuously pull my monorepo from GitHub. Each nginx pod had two containers – git-sync pulling the repo, and nginx serving the public/ directories.
I had 8 Hugo sites in a monorepo called webrepo, each with their own Hugo config, theme, and content directory. The existing GitHub Actions workflow would build Hugo, commit the public/ directory back to the repo, and git-sync would pick it up in K8s within a few minutes.
It worked. But it was absurdly over-engineered for static content.
The Pilot: DevShellNetBlog
Claude and I decided to start with a low-risk site – DevShellNetBlog, my dev/staging copy of a site. If we broke it, nobody would notice.
Creating the Cloudflare Pages Project
Claude used the wrangler CLI (Cloudflare’s tool) to create the project:
wrangler pages project create dev-blog-shellnetsecurity --production-branch master
✨ Successfully created the 'dev-blog-shellnetsecurity' project.
Then deployed the existing built files:
wrangler pages deploy DevShellNetBlog/public \
--project-name dev-blog-shellnetsecurity \
--branch master --commit-dirty=true
Uploading... (433/433)
✨ Success! Uploaded 433 files (11.04 sec)
✨ Deployment complete!
Just like that, the site was live at dev-blog-shellnetsecurity.pages.dev. But we needed it on the real domain.
The DNS Cutover
The domain dev-blog.shellnetsecurity.com was a CNAME pointing to the DO App Platform. Claude updated it to point to Cloudflare Pages:
doctl compute domain records update shellnetsecurity.com \
--record-id 341252332 \
--record-type CNAME \
--record-name dev-blog \
--record-data dev-blog-shellnetsecurity.pages.dev.
Then added the custom domain to the Pages project via the Cloudflare API:
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects/dev-blog-shellnetsecurity/domains" \
-H "Authorization: Bearer ${TOKEN}" \
--data '{"name":"dev-blog.shellnetsecurity.com"}'
SSL provisioned automatically. Within a couple minutes:
curl -sI https://dev-blog.shellnetsecurity.com/ | grep server
server: cloudflare
Confirmed – traffic was going directly to Cloudflare Pages, completely bypassing the K8s pipeline.
Verifying It: The HTML Comment That Disappeared
We needed a way to prove content changes were deploying through the new pipeline. Claude added an HTML comment to the site’s head partial:
<!-- cf-pages-deploy-verified:2026-03-31 -->
Pushed it, the GitHub Action built Hugo and deployed to Pages. We checked:
curl -s https://dev-blog.shellnetsecurity.com/ | grep "cf-pages-deploy-verified"
Nothing. Empty output.
Hugo strips HTML comments in production mode. The hugo -e production command minifies output and removes comments. A detail neither of us caught initially.
The fix was obvious once we knew the cause – use a meta tag instead:
<meta name="cf-pages-verified" content="2026-03-31">
curl -s https://dev-blog.shellnetsecurity.com/ | grep "cf-pages-verified"
<meta name="cf-pages-verified" content="2026-03-31">
There it is. End-to-end verified.
The Change Detection Bug
The GitHub Actions workflow used a change detection matrix to only build sites that had changed. Here’s the original logic:
DIRS=$(echo '${{ steps.changed-files.outputs.all_changed_files }}' \
| xargs -n1 dirname | sort -u \
| grep -E '^(DevShellNetBlog)$')
This worked when changes were to files in the root of the site directory. But when I changed DevShellNetBlog/layouts/partials/head_custom.html, dirname returned DevShellNetBlog/layouts/partials – not DevShellNetBlog. The grep for ^DevShellNetBlog$ didn’t match. The build was skipped entirely.
The fix was simple – use cut instead of dirname to extract just the top-level directory:
DIRS=$(echo '${{ steps.changed-files.outputs.all_changed_files }}' \
| xargs -n1 | cut -d'/' -f1 | sort -u \
| grep -E '^(DevShellNetBlog)$')
A one-line change that cost us a build cycle to debug.
Scaling to All 7 Sites
With the pilot validated, we migrated all remaining sites. The workflow needed to map site directories to Cloudflare Pages project names since they didn’t match (e.g., SkinDeep → skindeep-tattoo, ShellNetBlog → shellnet-blog).
Claude built a bash associative array into the workflow:
- name: Set matrix
id: set-matrix
run: |
declare -A PROJECT_MAP
PROJECT_MAP[DevShellNetBlog]=dev-blog-shellnetsecurity
PROJECT_MAP[SkinDeep]=skindeep-tattoo
PROJECT_MAP[TattooLuv]=tattooluv
PROJECT_MAP[MommaBears]=mommabears
PROJECT_MAP[ShellNetBlog]=shellnet-blog
PROJECT_MAP[InkedWith]=inkedwith
PROJECT_MAP[PixieDustPrincess]=pixiedustprincess
CHANGED_DIRS=$(echo '${{ steps.changed-files.outputs.all_changed_files }}' \
| xargs -n1 | cut -d'/' -f1 | sort -u \
| grep -E '^(DevShellNetBlog|SkinDeep|TattooLuv|...)$')
MATRIX="["
for DIR in $CHANGED_DIRS; do
PROJECT=${PROJECT_MAP[$DIR]}
MATRIX+="{\"site_dir\":\"$DIR\",\"project_name\":\"$PROJECT\"}"
done
MATRIX+="]"
Each site independently triggers its own build and deploys to its own Pages project. Changing SkinDeep/ doesn’t rebuild ShellNetBlog/.
The 51MB File That Blocked ShellNetBlog
When Claude tried to deploy ShellNetBlog to Cloudflare Pages:
testing_data/original.dng is 51.6 MiB in size
Cloudflare Pages has a 25MB per-file limit. There was a 51MB raw camera file (.dng) sitting in the blog’s static/testing_data/ directory. Was it referenced anywhere?
grep -r "original.dng" ShellNetBlog/content/ ShellNetBlog/layouts/
Nothing. Dead file. Deleted it, deploy succeeded:
rm ShellNetBlog/static/testing_data/original.dng
wrangler pages deploy ShellNetBlog/public --project-name shellnet-blog
✨ Success! Uploaded 2475 files (62.08 sec)
The store.inkedwith.com Redirect Problem
One of my domains, store.inkedwith.com, was a simple 301 redirect to www.tattooluv.com. In the K8s reverse proxy config, it was one line:
return 301 https://www.mommabears.us/page/tattoo-luv-product/;
I figured Cloudflare Pages’ _redirects file would handle it:
https://store.inkedwith.com/* https://www.tattooluv.com/:splat 301
It didn’t work. Cloudflare Pages’ _redirects file only matches URL paths, not hostnames. Since store.inkedwith.com and www.inkedwith.com both hit the same Pages project, the _redirects file couldn’t differentiate between them.
The solution was a Cloudflare Pages Worker – a tiny JavaScript file that runs at the edge:
// InkedWith/static/_worker.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.hostname === "store.inkedwith.com") {
return Response.redirect("https://www.tattooluv.com/", 301);
}
return env.ASSETS.fetch(request);
},
};
But then Hugo struck again. Hugo ignores files starting with _ in the static/ directory. The _worker.js file never made it into the build output. We added a copy step to the GitHub Actions workflow:
- name: Copy CF Pages worker if present
run: |
if [ -f "static/_worker.js" ]; then
cp "static/_worker.js" "public/_worker.js"
fi
After that, the redirect worked:
curl -sI https://store.inkedwith.com/ | grep location
location: https://www.tattooluv.com/
Cloudflare’s Disappearing Pages UI
One amusing moment: we tried to set up the Cloudflare Pages GitHub integration through the dashboard. Cloudflare has been merging Pages into Workers, and the UI is in a transitional state. I couldn’t find a “Pages” option anywhere – only “Create application” for Workers.
Claude’s research confirmed: Cloudflare is converging Pages and Workers into a unified product. The classic “Connect to Git” flow for Pages was gone (or hidden) in my account. We gave up on the native GitHub integration and built the GitHub Actions workflow instead – which honestly worked better since we have full control over the build process.
The Cleanup
With all 7 sites migrated to Cloudflare Pages, we removed them from K8s:
nginx StatefulSet – 2 replicas, each with a git-sync sidecar. Deleted:
kubectl delete statefulset nginx
kubectl delete service nginx
kubectl delete configmap nginx-config
Reverse proxy – stripped down to just n8n (the only remaining dynamic service).
DO App Platform – removed all migrated domains from the app spec.
Old GitHub Actions workflow – deleted entirely. The new deploy_cloudflare_pages.yml handles everything.
The Traffic Test
Before removing the old infrastructure, Claude checked the DO App Platform logs to verify no migrated sites were still receiving traffic:
doctl apps logs 412b699c-... --type run | tail -200 | \
grep "'host' : '" | sort | uniq -c | sort -rn
36 'host' : 'store.inkedwith.com'
6 'host' : 'automation.shellnetsecurity.com'
2 'host' : 'shellnetsecurity.com'
Zero traffic for any of the migrated sites. DNS had fully propagated. Safe to delete.
What We Built
The final Cloudflare Pages workflow handles 9 sites total – 7 Hugo sites, 1 static HTML site (PixieDustPrincess), and 1 static content server (ContentShellNet). Each builds independently, deploys to its own Pages project, and has its own custom domain with automatic SSL.
The deploy_cloudflare_pages.yml workflow:
- Detects which site directory changed
- Maps it to the correct CF Pages project name
- Builds Hugo (or skips for static sites)
- Copies any
_worker.jsfiles that Hugo would have ignored - Deploys via
wrangler pages deploy
Total build time: about 1 minute per site.
Coming up in Part 3, we tackle the remaining infrastructure – replacing the DO App Platform and Load Balancer with a Cloudflare Tunnel, and the DNS migration that almost broke everything.
Every command in this post was executed live in a terminal session with Claude Code. The bugs were real, the debugging was real, and the infrastructure changes were made in production with no staging environment. This is what “move fast” actually looks like.