Self-hosting the blog on a Raspberry Pi behind a Cloudflare Tunnel

For about three years this site lived on a $6 VPS and I never thought about it, which is exactly what you want from hosting. But I've got a walk-in closet full of hardware and more idle compute than a small county, so it started to feel silly paying someone else to run a Django app that gets a few hundred visits a month.

So: bring it home. The constraints I set for myself, in order of how much I cared:

  • No inbound ports on the home firewall. None.
  • If the Pi gets popped, the blast radius is the Pi — not the NAS, not Home Assistant, not the kids' iPads.
  • /admin is reachable from my LAN only. The public internet doesn't need to know it exists.1

Why a Pi, and not the cluster

Because the cluster is where I experiment, and this needs to be boring. A Pi 5 with an NVMe hat, Docker, and a Postgres container is more than enough, and it can sit on its own VLAN without me having to think about how the Proxmox bridge is tagged.2

Build log
This post is the write-up for personal-site v1.0. The earlier attempt (v0.2, "just port-forward it") lasted about a day before I lost my nerve.

The isolated VLAN

VLAN 40 gets exactly one host and one rule: it can talk out to the internet, and nothing on the LAN can initiate a connection to it except from my desk. Everything else is dropped.

internet ──▶ cloudflare edge ──▶ tunnel (outbound only) ──▶ pi (VLAN 40)
                                                              │
LAN (VLAN 10) ── desk only ──▶ pi:8000/admin ◀────────────────┘
                     everything else ✕ dropped

The firewall rules, for the record:

Rule Source Destination Action
1 VLAN 40 Internet Allow
2 Desk (10.40.10.5) VLAN 40 :8000 Allow
3 VLAN 40 Any RFC1918 Drop
4 Any VLAN 40 Drop

Cloudflare Tunnel setup

The tunnel runs as a container beside the app. The whole config is about six lines, which still feels like cheating:

tunnel: kyleinprogress
credentials-file: /etc/cloudflared/creds.json
ingress:
  - hostname: www.kyleinprogress.com
    service: http://web:8000
  - service: http_status:404

The last rule matters: anything that isn't the hostname gets a 404 at the edge and never touches the Pi.

Bringing it up is two commands:

docker compose pull
docker compose up -d web cloudflared

Take systems seriously. Not yourself. Also, back up the database before you "just quickly" migrate it.

Keeping /admin local

Two layers. Django's ALLOWED_HOSTS only accepts the public hostname, and the admin URL is gated by middleware that checks the request's source subnet:

import ipaddress
from django.conf import settings
from django.http import Http404


class AdminNetworkMiddleware:
    """Return 404 for admin paths unless the client is on an allowed network."""

    def __init__(self, get_response):
        self.get_response = get_response
        self.networks = [ipaddress.ip_network(n) for n in settings.ADMIN_ALLOWED_NETWORKS]

    def __call__(self, request):
        if request.path.startswith(f"/{settings.ADMIN_URL}") and self.networks:
            client = ipaddress.ip_address(request.META["REMOTE_ADDR"])
            if not any(client in net for net in self.networks):
                raise Http404
        return self.get_response(request)

The tunnel ingress never forwards /admin at all, so even if the middleware had a bug the path doesn't exist from outside.

What broke

1. Static files

WhiteNoise wasn't in the container image, so the first deploy was a beautifully unstyled site for about four minutes. Progress.

2. Time zones

The Pi came up in UTC, Postgres in UTC, and my post dates shifted by a day. Fine for a blog; would not be fine for a court CMS, which is a sentence I say to myself a lot.


Total cost: $0/month going forward, one afternoon, and a slightly better understanding of my own firewall.


  1. That's not the actual admin url...I may be an Aggie, but I know not post a "secret" url on a blog post about hiding it. ↩

  2. The Proxmox bridge is tagged correctly now. It was not, for about a week, and that is a different post. ↩

Comments

No comments yet. Be the first to share your thoughts!