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
- Part 3: Killing the Load Balancer with Cloudflare Tunnel (you are here)
- 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
What Was Left After the Static Site Migration
After moving all static sites to Cloudflare Pages, the only thing left going through the DigitalOcean App Platform and Load Balancer was n8n – my workflow automation tool at automation.shellnetsecurity.com.
The traffic path was:
Browser → Cloudflare → DO App Platform ($10/mo)
→ DO Load Balancer ($12/mo)
→ K8s reverse-proxy StatefulSet (3 nginx replicas)
→ n8n pod
$22/month and 3 reverse proxy pods to reach one internal service. And I confirmed that n8n didn’t receive any external webhooks – I only accessed it myself. Which meant the entire public infrastructure stack was unnecessary.
The Plan: Cloudflare Tunnel
Cloudflare Tunnel creates an outbound connection from inside your infrastructure to Cloudflare’s edge. No public IP needed, no load balancer, no port forwarding. Traffic flows:
Browser → Cloudflare Edge → Encrypted Tunnel → cloudflared pod in K8s → n8n
The best part? It’s free. No bandwidth limits, no request limits.
Creating the Tunnel
Claude created the tunnel via the Cloudflare API:
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \
-H "Authorization: Bearer ${TOKEN}" \
--data '{
"name": "k8s-n8n-tunnel",
"tunnel_secret": "'$(openssl rand -base64 32)'",
"config_src": "cloudflare"
}'
This returned a tunnel ID and token. Then configured the ingress routing:
curl -X PUT ".../cfd_tunnel/${TUNNEL_ID}/configurations" \
--data '{
"config": {
"ingress": [
{
"hostname": "automation.shellnetsecurity.com",
"service": "http://n8n.n8n.svc.cluster.local:5678"
},
{"service": "http_status:404"}
]
}
}'
That tells the tunnel: any request for automation.shellnetsecurity.com should be forwarded to the n8n Kubernetes service at n8n.n8n.svc.cluster.local:5678.
The DNS Problem That Broke Everything
Claude updated the DNS CNAME for automation.shellnetsecurity.com to point to the tunnel:
doctl compute domain records update shellnetsecurity.com \
--record-id 1804763691 \
--record-type CNAME \
--record-name automation \
--record-data 574751b4-6bfb-46e7-8b74-1a252f17eb09.cfargotunnel.com.
DNS propagated. We deployed cloudflared to K8s. Everything looked good in the logs:
INF Registered tunnel connection connIndex=0 location=sjc07 protocol=quic
INF Registered tunnel connection connIndex=1 location=sjc01 protocol=quic
Four tunnel connections established. We tested:
curl https://automation.shellnetsecurity.com/
Could not resolve host: automation.shellnetsecurity.com
Nothing. The tunnel CNAME (*.cfargotunnel.com) wasn’t resolving.
The Root Cause
Cloudflare Tunnel requires the hostname to be proxied through Cloudflare’s network. The tunnel CNAME only works when Cloudflare’s edge intercepts the DNS query and routes it to the tunnel. But my DNS was hosted on DigitalOcean, not Cloudflare. The CNAME to cfargotunnel.com was just pointing to… nothing resolvable.
dig 574751b4-6bfb-46e7-8b74-1a252f17eb09.cfargotunnel.com @8.8.8.8
Empty response. The cfargotunnel.com domain only exists within Cloudflare’s proxy layer.
The fix: migrate DNS to Cloudflare. The entire shellnetsecurity.com zone needed to be on Cloudflare so the proxy could intercept tunnel hostnames.
The DNS Migration
This was the scariest part. Moving DNS for a domain that had 30+ records, including mail (Mailgun), email authentication (SPF, DKIM, DMARC), site verification (Google, Pinterest), ConvertKit email marketing, and all the Cloudflare Pages CNAMEs we just set up.
I created the zone in the Cloudflare dashboard (the API token didn’t have zone creation permissions – wrangler’s OAuth token is scoped for Workers/Pages, not zone management). Cloudflare’s auto-import scanned and pulled in records it could detect.
Then I changed the nameservers at my registrar from DigitalOcean’s to Cloudflare’s:
ns1.digitalocean.com → lily.ns.cloudflare.com
ns2.digitalocean.com → marek.ns.cloudflare.com
ns3.digitalocean.com → (removed)
But when we checked the imported records, several were missing:
dig automation.shellnetsecurity.com A +short @lily.ns.cloudflare.com
# (empty)
dig dev-blog.shellnetsecurity.com A +short @lily.ns.cloudflare.com
# (empty)
dig content.shellnetsecurity.com A +short @lily.ns.cloudflare.com
# (empty)
The auto-import missed the records that had recently been changed to point to Cloudflare Pages – probably because they were already CNAMEs to *.pages.dev and the scanner didn’t pick them up. I had to manually add them in the dashboard.
The critical one was automation – it had to be proxied (orange cloud in Cloudflare’s UI) for the tunnel to work. DNS-only (grey cloud) won’t route to the tunnel.
After adding the missing records:
curl -sL https://automation.shellnetsecurity.com/ | head -1
n8n was live through the tunnel.
The Wrangler OAuth Token Saga
A recurring annoyance throughout this whole process: wrangler’s OAuth token kept expiring. Every 30-60 minutes, API calls would start failing:
{"code": 10000, "message": "Authentication error"}
We’d have to trigger a wrangler command (like wrangler pages project list) to force a token refresh, then re-read the token from the config file. At one point, even that stopped working and we had to manually refresh using the OAuth refresh token endpoint. Eventually I created a proper API token in the Cloudflare dashboard, which solved the problem permanently.
Lesson learned: use proper API tokens for automation, not OAuth session tokens.
Deploying cloudflared via Terraform
I didn’t want cloudflared to be a manually-deployed pod. My K8s infrastructure is managed through a mix of Terraform and GitHub Actions – pushing to the terraform/ directory triggers terraform apply automatically.
Claude created terraform/cloudflared.tf:
variable "cloudflare_tunnel_token" {
type = string
sensitive = true
}
resource "kubernetes_secret" "cloudflare_tunnel_token" {
metadata {
name = "cloudflare-tunnel-token"
namespace = "default"
}
data = {
token = var.cloudflare_tunnel_token
}
}
resource "kubernetes_deployment" "cloudflared" {
metadata {
name = "cloudflared"
labels = { app = "cloudflared" }
}
spec {
replicas = 1
selector {
match_labels = { app = "cloudflared" }
}
template {
metadata {
labels = { app = "cloudflared" }
}
spec {
container {
name = "cloudflared"
image = "cloudflare/cloudflared:latest"
args = ["tunnel", "--no-autoupdate", "run",
"--token", "$(TUNNEL_TOKEN)"]
env {
name = "TUNNEL_TOKEN"
value_from {
secret_key_ref {
name = kubernetes_secret.cloudflare_tunnel_token.metadata[0].name
key = "token"
}
}
}
resources {
requests = { memory = "64Mi", cpu = "50m" }
limits = { memory = "128Mi" }
}
}
}
}
}
}
The tunnel token was stored as a GitHub Actions secret (TF_VAR_CLOUDFLARE_TUNNEL_TOKEN), passed through to Terraform as a sensitive variable. Push to main, Terraform applies, cloudflared starts, tunnel connects.
The Delete Button
With the tunnel working, it was time to delete $22/month of infrastructure:
# Delete the reverse proxy (3 pods, no longer needed)
kubectl delete statefulset reverse-proxy
kubectl delete service reverse-proxy
kubectl delete configmap proxy-config
# Delete the DO App Platform
doctl apps delete 412b699c-0527-4265-8f66-771172f01163 --force
# The Load Balancer auto-deleted when the K8s service was removed
App Platform: gone. Load Balancer: gone. Reverse proxy: gone.
The only thing publicly accessible is automation.shellnetsecurity.com through the Cloudflare Tunnel. Everything else is on Cloudflare Pages (static sites) or internal-only (databases, redis, searxng).
Security Bonus: Cloudflare Access
With the tunnel in place, I can add Cloudflare Access (part of their free Zero Trust tier) to put an authentication layer in front of n8n. This means:
- Require login via Google/GitHub/email before reaching n8n
- Restrict to specific email addresses
- Unauthorized requests get blocked at Cloudflare’s edge – they never reach my cluster
That’s a future enhancement, but the infrastructure is in place for it now. For free. On the old architecture, adding authentication would have meant deploying an OAuth proxy or configuring nginx auth – way more work.
The Numbers
| Before | After |
|---|---|
| DO App Platform: $10/mo | Cloudflare Tunnel: $0/mo |
| DO Load Balancer: $12/mo | (eliminated) |
| Reverse Proxy: 3 pods | cloudflared: 1 pod |
| $22/month | $0/month |
Next up in Part 4: I take a detour from cloud cost optimization and build a home router from a Raspberry Pi 5 running OpenWrt. Because why optimize your cloud bill if you’re paying Comcast to run your network poorly?
The DNS migration was the moment where “yolo” felt the most real. Moving nameservers for a production domain with 30+ records, mail delivery, and active websites – with Claude Code making the changes in real-time. Everything worked. But I won’t pretend my palms weren’t sweaty.