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:

  1. Clear WordPress transients (cache)
  2. Regenerate the theme CSS/JS via the theme customiser
  3. 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:

  1. Update Chart.yaml to pin the new chart version:
dependencies:
  - name: wordpress
    version: "32.1.13"  # Update this number
  1. Refresh dependencies:
helm dependency update
  1. Upgrade the deployment:
helm upgrade wordpress . -n wordpress
  1. 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

  1. Single-attach storage is your enemy during updates. Always use Recreate strategy with Longhorn (or any RWO storage). I learned this the hard way.


  2. 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.


  3. 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.


  4. 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.


  5. 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:

By admin

Leave a Reply

Your email address will not be published. Required fields are marked *