I wanted a blog. Not a WordPress instance, not a hosted platform, not something I pay monthly for. A static site built with Hugo, baked into a container image, served by nginx, tunneled through Cloudflare, running on my Kubernetes cluster at home. No ports exposed to the internet. No public IP pointing at my house. Just a Cloudflare Tunnel and a reverse proxy.

This is how it works, and how some of the services behind it are locked down with Google OAuth.

Contents#

The Architecture#

The request path for someone hitting blog.yourdomain.tld:

Browser → Cloudflare CDN → Cloudflare Tunnel → SWAG pod (nginx) → static files

No inbound firewall rules. No port forwarding. No NAT. Cloudflare Tunnel creates an outbound-only connection from the SWAG pod to Cloudflare’s edge network. Traffic flows in through that tunnel. The FortiGate never sees an inbound connection from the internet to the cluster.

For other services that SWAG proxies, the path is similar but ends at a different backend:

Browser → Cloudflare CDN → Cloudflare Tunnel → SWAG pod (nginx) → ClusterIP service

SWAG handles TLS termination, subdomain routing, and reverse proxying to internal Kubernetes services. Each service gets its own subdomain (navidrome.yourdomain.tld, aurral.yourdomain.tld, plex.yourdomain.tld) with its own nginx server block.

SWAG: The Reverse Proxy#

SWAG (Secure Web Application Gateway) is a LinuxServer.io container that bundles nginx, Let’s Encrypt/Cloudflare DNS validation, and a bunch of pre-built reverse proxy configs. I run a custom image that adds the blog content and Cloudflare Tunnel support.

The SWAG pod runs as a LoadBalancer service on the cluster, claiming the MetalLB VIP at 10.x.x.x. This is the single IP that all internal DNS overrides point at for yourdomain.tld subdomains. LAN clients hit the VIP directly. Internet clients come through the Cloudflare Tunnel.

The Custom Image#

The blog is built at image build time using Hugo, then baked into the SWAG container:

FROM hugomods/hugo:latest AS blog-builder
WORKDIR /blog
COPY blog-content/ .
RUN git init && \
    git submodule add https://github.com/panr/hugo-theme-terminal.git themes/terminal && \
    hugo --minify

FROM lscr.io/linuxserver/swag:latest
COPY www/ /defaults/www/
COPY --from=blog-builder /blog/public/ /defaults/www/blog/

RUN mkdir -p /custom-cont-init.d && \
    echo '#!/bin/bash' > /custom-cont-init.d/sync-blog.sh && \
    echo 'cp -r /defaults/www/blog /config/www/blog' >> /custom-cont-init.d/sync-blog.sh && \
    chmod +x /custom-cont-init.d/sync-blog.sh

Multi-stage build: Hugo compiles the markdown into static HTML in the first stage, then the output gets copied into the SWAG image. The sync-blog.sh init script copies the blog files into the config volume on every container start, because SWAG only populates /config/www from /defaults/www on first run.

The image is built and pushed to ECR by a GitHub Actions workflow in the heezy-containers repo. Push blog content, image rebuilds, SWAG pod gets restarted, new content is live.

Proxy Configuration#

Each proxied service gets an nginx server block mounted as a ConfigMap:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name navidrome.*;
    include /config/nginx/ssl.conf;
    client_max_body_size 0;
    location / {
        include /config/nginx/proxy.conf;
        include /config/nginx/resolver.conf;
        set $upstream_app navidrome.heezy.svc.cluster.local;
        set $upstream_port 4533;
        set $upstream_proto http;
        proxy_pass $upstream_proto://$upstream_app:$upstream_port;
    }
}

The set $upstream_app with a variable is important. It forces nginx to resolve the DNS name at request time instead of at startup. If the backend pod moves to a different node and gets a new ClusterIP, nginx picks it up without a restart. The resolver ConfigMap points at the Kubernetes DNS service so nginx can resolve *.heezy.svc.cluster.local names.

The blog is different. It’s not a proxy, it’s static files served directly:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name blog.*;
    include /config/nginx/ssl.conf;
    root /config/www/blog;
    index index.html;
    location / {
        try_files $uri $uri/ =404;
    }
}

The Cloudflare Tunnel#

SWAG has a Docker mod (linuxserver/mods:universal-cloudflared) that runs cloudflared as a sidecar process inside the container. The tunnel config is mounted as a ConfigMap:

tunnel: swagpipelineproxy
credentials-file: /tmp/tunnel-credentials.json
ingress:
  - hostname: "*.yourdomain.tld"
    service: https://localhost:443
    originRequest:
      noTLSVerify: true
  - hostname: yourdomain.tld
    service: https://localhost:443
    originRequest:
      noTLSVerify: true
  - service: http_status:404

Wildcard hostname match. Everything for *.yourdomain.tld gets sent to localhost:443 (nginx inside the same container). noTLSVerify: true because the connection from cloudflared to nginx is localhost, and the Let’s Encrypt cert is for the public domain, not for internal routing.

Tunnel credentials (zone ID, account ID, API token, tunnel password) are stored in AWS Secrets Manager and synced into the cluster via External Secrets Operator.

Locking Services Behind Google OAuth#

Some services should be publicly accessible (the blog, Plex). Others should require authentication. Cloudflare Access provides this without touching the application itself.

How It Works#

Cloudflare Access sits in front of the tunnel. Before a request reaches SWAG, Cloudflare checks if the user is authenticated. If not, they get redirected to a Google OAuth login page. After authenticating, Cloudflare issues a JWT and the request passes through to the tunnel.

The setup:

  1. Create a Google OAuth application in the Google Cloud Console
  2. Configure Cloudflare Access with the Google identity provider
  3. Create Access policies per subdomain (or wildcard with exceptions)

For a service like Overseerr (a request portal where friends and family can browse and request content), the Access policy allows anyone with a Google account in a specific list. They hit overseerr.yourdomain.tld, get the Google login prompt, authenticate, and then they’re through to the app. The app itself doesn’t know about the auth layer. It just sees authenticated requests.

For the blog and Plex, the Access policy is set to bypass. No login required.

Why Not App-Level Auth?#

Some of these services have their own authentication. But having Cloudflare Access in front means:

  • One consistent login experience across all services
  • No exposed login pages for brute force attacks
  • Services that don’t have good auth (or any auth) still get protected
  • I can revoke access for a specific person without touching every app
  • The apps never see unauthenticated traffic at all

The Deployment Pipeline#

Updating the blog:

  1. Write markdown in heezy-containers/dockerfiles/swag/blog-content/
  2. Push to main
  3. GitHub Actions builds the Hugo site and the SWAG image
  4. Image gets pushed to ECR
  5. A separate workflow restarts the SWAG deployment in the k8s cluster
  6. New pod pulls the latest image, blog is live

Updating proxy configs:

  1. Edit the ConfigMap in heezy-k8s/apps/swag/proxy-confs-configmap.yaml
  2. Push to main
  3. Auto-deploy applies the ConfigMap
  4. Restart the SWAG pod to pick up the new config

Adding a new proxied service:

  1. Add a new server block to the proxy-confs ConfigMap
  2. Add the volume mount to the SWAG deployment
  3. Add the Cloudflare Access policy (if auth is needed)
  4. Push both changes, auto-deploy handles the rest

LAN vs Internet Access#

LAN clients and internet clients hit the same URLs but take different paths:

Internet: navidrome.yourdomain.tld → Cloudflare DNS → Cloudflare Tunnel → SWAG → ClusterIP

LAN: navidrome.yourdomain.tld → dnsmasq override → 10.x.x.x (MetalLB VIP) → SWAG → ClusterIP

The dnsmasq split-horizon DNS setup (covered in the DNS blog post) makes this transparent. Same URL, same TLS cert, different network path. LAN clients skip Cloudflare entirely, so there’s no latency penalty and no dependency on the internet for local access.

What I’d Do Differently#

Hugo was the right call for the blog. Static site generation means the “server” is just nginx serving files. No database, no runtime, no attack surface beyond nginx itself.

The custom SWAG image approach works but means a full image rebuild for every blog post. If I were doing it again, I might mount the blog content as a separate volume or ConfigMap so I could update it without rebuilding the entire image. But the build is fast (Hugo compiles in seconds) and the pipeline is automated, so it’s not a real pain point.

Cloudflare Access is ass-wipe mode zero-trust. Self-hosted services without exposing anything to the internet. The Google OAuth integration took about 15 minutes to set up and it just works.