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.
/adminis 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
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.
No comments yet. Be the first to share your thoughts!