A Tale of Public Access, Private Administration, and Learning the Hard Way
Published: 17 July 2026
Reading time: ~12 minutes

Introduction
I recently deployed a WordPress blog to Kubernetes with some rather ambitious networking requirements: expose it publicly via Cloudflare Tunnel on www.daveknowstech.com whilst simultaneously maintaining private admin access through Tailscale on blog.safehomelan.com. Sounds simple, yeah? It’s not. But it’s brilliant once it works, and I’ve learned far more about Kubernetes, networking, and WordPress than I ever expected to.
This post walks you through the entire setup, the problems we encountered, and how we solved them. If you’re considering something similar, you’ll learn what to avoid. If you’re already struggling with it, you’ll find the solutions.
Architecture Overview: The Dream Setup
Here’s what we wanted to achieve:
Public Layer (Internet)
https://www.daveknowstech.com → Cloudflare Edge (DDoS, caching, security rules)
→ Cloudflare Tunnel (cloudflared daemon)
→ Kubernetes Service
Private Layer (Tailscale VPN)
https://blog.safehomelan.com → Direct pod-to-pod communication (internal K8s DNS)
→ WordPress admin interface (NO blocks)
The idea is that random folks on the internet can read your blog but can’t touch /wp-admin/ or /wp-login.php (Cloudflare blocks them with HTTP 403), while you can administer the site privately via Tailscale. Elegant.
Part 1: The Infrastructure Stack
Helm Chart Wrapper
Rather than managing raw Kubernetes manifests, I wrapped the excellent Bitnami WordPress chart in a custom Helm chart. This gives us:
- Longhorn storage: 100GB PVC for WordPress files, 20GB for MariaDB
- Tailscale Ingress: Private hostname exposed via Tailscale operator
- Embedded MariaDB: Database lives in the same namespace (simpler for self-hosted)
- Internet egress: Pods can pull plugins/themes from WordPress.org
The wrapper is slim—just a Chart.yaml declaring the upstream Bitnami dependency, plus values.yaml to customise it.
# Chart.yaml
apiVersion: v2
name: wordpress
description: WordPress 7.x deployment with Longhorn PVC and Tailscale ingress
type: application
version: 1.0.1
appVersion: 7.0.2
dependencies:
- name: wordpress
version: "32.1.13"
repository: "https://charts.bitnami.com/bitnami"
alias: wordpress
Key values configuration:
wordpress:
# Pin the image tag and use Always pull policy to ensure we get the latest
image:
tag: "latest"
pullPolicy: Always
# CRITICAL: Longhorn volumes are single-attach; rolling updates will deadlock
updateStrategy:
type: Recreate
# Persistent storage for WordPress files
persistence:
enabled: true
storageClass: longhorn
size: 100Gi
mountPath: /bitnami/wordpress
# Tailscale ingress for private admin access
ingress:
enabled: true
className: tailscale
annotations:
tailscale.com/expose: "true"
hosts:
- host: blog.chocolate-rattlesnake.ts.net
paths:
- path: /
pathType: Prefix
Cloudflare Tunnel Daemon
Alongside the WordPress Helm deployment, I created a separate Kubernetes Deployment for the Cloudflare Tunnel daemon (cloudflared):
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: wordpress
spec:
replicas: 2
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- --metrics
- 0.0.0.0:2000
- run
- --token
- $(CLOUDFLARE_TUNNEL_TOKEN)
env:
- name: CLOUDFLARE_TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-tunnel-wordpress
key: tunnel-token
livenessProbe:
httpGet:
path: /ready
port: 2000
initialDelaySeconds: 10
timeoutSeconds: 1
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
The tunnel token comes from the Cloudflare dashboard and gets stored in a Kubernetes secret.
Part 2: Cloudflare Configuration
Once the cloudflared pods are running and registering themselves with Cloudflare, you need to configure the public hostname and routing.
DNS Setup
Create a CNAME record pointing www to your tunnel:
Type: CNAME
Name: www
Content: <tunnel-id>.cfargotunnel.com
Proxied: Yes (orange cloud)
TTL: Auto
Cloudflare auto-creates this when you set up the tunnel via the dashboard, but you might need to tweak it.
Public Hostname Configuration
In Cloudflare Zero Trust → Networks → Tunnels → wordpress-prod, add a public hostname:
Hostname: www.daveknowstech.com
Service: http://10.43.46.151:80 (Kubernetes ClusterIP)
Path: / (catch-all)
Here’s a crucial detail: we route to the Kubernetes Service ClusterIP (10.43.46.151), not a Tailscale IP. Why? Because cloudflared pods run inside the cluster and can talk directly to K8s DNS. Using the ClusterIP avoids CGNAT nastiness and simplifies routing.
Security Rules: The Gatekeepers
All the fun happens here. Create three Cloudflare Security Rules:
Rule 1: Block /wp-login.php
Condition: URI Path equals /wp-login.php
Action: Block (HTTP 403)
Status: Enabled
Rule 2: Block /wp-admin/
Condition: URI Path contains /wp-admin/
Action: Block (HTTP 403)
Status: Enabled
Rule 3: Block /wp-json/wp/v2/users
Condition: URI Path contains /wp-json/wp/v2/users
Action: Block (HTTP 403)
Status: Enabled
These live at the Cloudflare edge and block requests before they reach your origin. Test them:
curl -I https://www.daveknowstech.com/wp-login.php
# Expected: HTTP/2 403
curl -I https://www.daveknowstech.com/
# Expected: HTTP/2 200
Part 3: The Problems (and Solutions)
Problem 1: Cloudflared Pods in CrashLoopBackOff
When we first deployed the cloudflared daemon, the pods kept crashing immediately. The logs showed clean exits (exit code 0), which was baffling.
Root cause: The liveness probe was looking for a metrics endpoint on port 2000, but we weren’t binding the metrics server to that port. Cloudflared would pick a random port, the probe would fail, and Kubernetes would restart the pod endlessly.
Solution: Explicitly bind metrics to port 2000 in the arguments:
args:
- tunnel
- --no-autoupdate
- --metrics 0.0.0.0:2000 # ← This was missing
- run
- --token
- $(CLOUDFLARE_TUNNEL_TOKEN)
And match the liveness probe:
livenessProbe:
httpGet:
path: /ready
port: 2000 # ← Must match --metrics port
Problem 2: Longhorn Multi-Attach Deadlock
When updating WordPress (from 7.0.1 to 7.0.2), the rollout hung. Kubernetes tried to start a new WordPress pod while the old one was still running, both trying to mount the same Longhorn PVC.
Root cause: Longhorn volumes are single-attach (RWO). By default, Kubernetes uses a rolling update strategy, which doesn’t work with single-attach storage.
Solution: Use Recreate update strategy:
wordpress:
updateStrategy:
type: Recreate
This stops the old pod before starting the new one, allowing the PVC to be reattached cleanly.
If you’re already stuck in this state:
# Scale down to 0 replicas (allows Longhorn to detach the volume)
kubectl scale deployment wordpress -n wordpress --replicas=0
# Wait a moment
sleep 10
# Scale back up
kubectl scale deployment wordpress -n wordpress --replicas=1
# Watch the new pod start
kubectl get pods -n wordpress -w
Problem 3: DNS Resolution in WordPress
WordPress was hardcoding the public domain (www.daveknowstech.com) in all internal links, which broke theme assets and login redirects when accessed via the internal Tailscale hostname (blog.safehomelan.com).
Root cause: The WP_HOME and WP_SITEURL constants in wp-config.php were set to the public domain, and WordPress always redirects to them.
Solution: Make these constants aware of the incoming hostname. Here’s a patch to wp-config.php:
if ( isset( $_SERVER['HTTP_HOST'] ) && in_array( $_SERVER['HTTP_HOST'], array( 'blog.safehomelan.com', 'www.daveknowstech.com' ), true ) ) {
define( 'WP_HOME', 'https://' . $_SERVER['HTTP_HOST'] );
define( 'WP_SITEURL', 'https://' . $_SERVER['HTTP_HOST'] );
} else {
// Fallback for other hostnames
define( 'WP_HOME', 'https://blog.safehomelan.com' );
define( 'WP_SITEURL', 'https://blog.safehomelan.com' );
}
This way, when someone accesses https://blog.safehomelan.com/wp-login.php, WordPress knows to keep them on blog.safehomelan.com instead of redirecting to the public hostname (which Cloudflare would block anyway).
You can apply this patch manually via kubectl cp and a Python script, or build it into a custom Dockerfile if you want it to persist across Helm upgrades.
Problem 4: Theme Graphics Missing
Even after patching the URL constants, theme graphics weren’t loading on the internal hostname. The issue was that the theme was configured with hardcoded URLs to asset files that didn’t match the current hostname.
Solution: This isn’t a networking issue—it’s a WordPress configuration issue. After patching the URL constants and flushing the cache, the theme should rebuild its asset URLs dynamically. If not, you might need to:
- Clear WordPress transients (cache)
- Regenerate the theme CSS/JS via the theme customiser
- Or manually update hardcoded URLs in the theme files
For our setup, flushing the cache resolved it:
kubectl exec -n wordpress deployment/wordpress -- wp cache flush --allow-root
Part 4: The Final Setup
Here’s what’s now running in the cluster:
Namespace: wordpress
Deployments:
✓ wordpress (1 replica, Bitnami image)
✓ cloudflared (2 replicas, Cloudflare tunnel)
StatefulSets:
✓ wordpress-mariadb (1 primary, internal database)
Services:
✓ wordpress (ClusterIP, port 80 → 8080)
✓ wordpress-mariadb (ClusterIP, port 3306 → internal only)
Ingress:
✓ wordpress (Tailscale, hostname: blog.chocolate-rattlesnake.ts.net)
PVCs:
✓ wordpress-longhorn (100Gi)
✓ wordpress-mariadb-longhorn (20Gi)
Secrets:
✓ cloudflare-tunnel-wordpress (tunnel token)
✓ wordpress-mariadb (DB credentials)
Access Paths
Public (Internet)
- Blog homepage:
https://www.daveknowstech.com(HTTP 200) - Blog posts:
https://www.daveknowstech.com/posts(HTTP 200) - Login page:
https://www.daveknowstech.com/wp-login.php(HTTP 403 ✓) - Admin panel:
https://www.daveknowstech.com/wp-admin/(HTTP 403 ✓)
Private (Tailscale VPN)
- Blog homepage:
https://blog.safehomelan.com(HTTP 200) - Login page:
https://blog.safehomelan.com/wp-login.php(HTTP 200, accessible) - Admin panel:
https://blog.safehomelan.com/wp-admin/(HTTP 200, accessible)
Part 5: Deployment and Maintenance
Initial Deployment
cd /home/david/code/gitea/helm/charts/wordpress
# Fetch Bitnami chart dependency
helm dependency update
# Create namespace
kubectl create namespace wordpress
# Deploy
helm upgrade --install wordpress . \
--namespace wordpress \
--values values.yaml
# Monitor rollout
kubectl rollout status deployment/wordpress -n wordpress --timeout=5m
Updating WordPress
When a new WordPress version is released (like 7.0.2), the Bitnami chart gets updated. To upgrade:
- Update
Chart.yamlto pin the new chart version:
dependencies:
- name: wordpress
version: "32.1.13" # Update this number
- Refresh dependencies:
helm dependency update
- Upgrade the deployment:
helm upgrade wordpress . -n wordpress
- Verify the new version:
kubectl exec -n wordpress deployment/wordpress \
-c wordpress -- wp core version --allow-root
# Should print: 7.0.2
Monitoring
# Check all pods
kubectl get pods -n wordpress
# View logs
kubectl logs -n wordpress -l app=cloudflared --tail=20
kubectl logs -n wordpress deployment/wordpress -c wordpress --tail=50
# Check Longhorn status
kubectl get pvc -n wordpress
ArgoCD Integration (Future)
Eventually, this Helm chart should be managed by ArgoCD rather than manual helm upgrade commands. The chart is already structured for this—just create an ArgoCD Application resource pointing at the chart path and let ArgoCD handle syncs.
Lessons Learned
Single-attach storage is your enemy during updates. Always use
Recreatestrategy with Longhorn (or any RWO storage). I learned this the hard way.WordPress is hostname-sensitive. If you’re serving the same WordPress instance on multiple hostnames, you must make the URL constants dynamic. Hardcoding doesn’t work.
Cloudflare Tunnel is brilliant. No port forwarding, no router configuration, no public IPs needed. Just run a daemon inside your cluster and let Cloudflare handle the edge.
Test your security rules before going public. It’s easy to accidentally allow admin paths when you’re setting up rules. Verify with
curl -I.Use Kubernetes ClusterIP instead of Tailscale IPs for internal routing. It’s simpler, faster, and avoids CGNAT issues.
Conclusion
Running WordPress on Kubernetes with dual-hostname access (public + private) is eminently doable, but it requires careful attention to storage strategies, networking, and WordPress configuration. The setup I’ve described here works reliably and securely—the blog is currently running on it, and I’ve been quite pleased.
If you’re planning something similar, use this as a starting point. The Helm chart and documentation are on GitHub, and all the solutions I’ve found are documented in the README.
Happy blogging!
Resources:
- Cloudflare Tunnel Documentation
- Bitnami WordPress Helm Chart
- Tailscale Kubernetes Operator
- Longhorn Storage Documentation