Self-Hosting on a Raspberry Pi

March 2025 · homelab · nginx · cloudflare · self-hosting

Everything on srtk.in, this site, my photos, my git, my passwords, my files, runs on hardware in my house. There is no cloud VM behind it. The whole thing is built around one idea: a tiny always-on machine should be the only thing that has to stay up. Everything expensive runs on a second machine that I can switch off whenever I'm not using it.

The two machines

There are exactly two boxes involved.

the internet Raspberry Pi always on · public IP · TLS · nginx ~3 W · serves this site on :5000 LAN 192.168.1.0/24 Home server on demand · Immich · Forgejo · Vaultwarden · files 192.168.1.217 · powered up when needed

The Pi is the front door. It is small and cheap enough that keeping it on costs nothing meaningful, and it has no spinning disks or hungry GPU to feed. The server is where the watts go, so the server is what gets switched off.

Getting traffic home: dynamic DNS

My home connection has a dynamic public IP, it changes whenever the ISP feels like it. DNS for the domain lives on Cloudflare, so a small Python script runs on a cron and keeps the records pointed at wherever home currently is. It updates two records: the apex srtk.in and a wildcard *.srtk.in, so every subdomain follows the same IP automatically.

RECORDS_TO_UPDATE = ["srtk.in", "*.srtk.in"]
PROXIED = False            # grey-cloud: direct connection, we port-forward ourselves

def main():
    current_ip = get_current_ip()                 # https://api.ipify.org
    for name in RECORDS_TO_UPDATE:
        record = get_dns_record(name)             # GET zones/{zone}/dns_records?name=
        if record["content"] != current_ip:
            update_dns_record(record["id"], name, current_ip)   # PUT, ttl=60

It only issues a write when the IP actually changed, and the TTL is kept low (60s) so a change propagates quickly. The records are not proxied through Cloudflare (grey cloud), traffic comes straight to the home router, which port-forwards 80/443 to the Pi.

The Cloudflare API token in the real script is scoped to a single permission: Zone → DNS → Edit on this one zone, and nothing else. A leaked token can rewrite my DNS, but it can't touch anything else in the account. Scope your tokens.

Wildcard TLS without exposing anything

Because every service is a subdomain, I use a single wildcard certificate for *.srtk.in from Let's Encrypt. A wildcard can only be issued via the DNS-01 challenge, which means certbot proves control of the domain by writing a TXT record through the Cloudflare API rather than by exposing an HTTP endpoint. nginx then loads one cert and key for all of it:

ssl_certificate     /etc/letsencrypt/live/srtk.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/srtk.in/privkey.pem;

Routing: the Pi as a pure edge proxy

nginx on the Pi knows each backend as a named upstream on the server's LAN address, and gives each subdomain its own server {} block. The Pi directly serves only this site (a small Flask app on :5000); everything else forwards to 192.168.1.217.

upstream photos { server 192.168.1.217:2283; }   # Immich
upstream git    { server 192.168.1.217:3000; }   # Forgejo
upstream passwd { server 192.168.1.217:1337; }   # Vaultwarden
upstream cloud  { server 192.168.1.217:5212; }   # file server
upstream dav    { server 192.168.1.217:9191; }   # WebDAV
upstream site   { server 127.0.0.1:5000;     }   # this site, on the Pi

# default server: anything that isn't a known host gets dropped
server { listen 80 default_server; return 301 https://$host$request_uri; }
server { listen 443 ssl default_server; return 444; }   # 444 = close, no response
request HTTPS :443 Host nginx (Pi) match server_name edge proxy this site :5000 Flask, served locally on the Pi Home server 192.168.1.217 photos :2283 Immich git :3000 Forgejo passwd :1337 Vaultwarden cloud :5212 files dav :9191 WebDAV unknown Host 444 connection closed, no page

That return 444 on the default HTTPS server is deliberate: any request with a Host header I don't recognise (scanners hitting the raw IP) gets the connection closed with no response, instead of leaking a default page.

Failing over gracefully when the server is off

This is the part that makes the on-demand model usable. When the server is powered down its upstreams are unreachable, and without handling every subdomain would throw a raw 502. Instead, each proxied service catches the failure and serves a friendly offline page from the Pi itself:

server {
    listen 443 ssl;
    server_name photos.srtk.in;

    location / {
        proxy_pass http://photos;
        proxy_next_upstream error timeout http_502 http_503 http_504;
        error_page 502 503 504 = /fallback;
    }

    location /fallback {
        proxy_pass http://localhost:5000/down;   # served by the always-on Pi
    }
}
server powered on server powered off GET photos.srtk.in :443 nginx proxy_pass photos server 192.168.1.217:2283 answers with a clean 200 happy path, three hops GET photos.srtk.in :443 nginx proxy_pass photos upstream unreachable, 502 error_page = /fallback to :5000/down Pi serves the downtime page fallback, the Pi covers for the server

The = /fallback form rewrites the response so the client gets a clean page from the Pi's always-on Flask app. Power the server back on and nginx resumes proxying to it with no further action.

For uploads, the Pi's proxy buffering does something nicer still: it absorbs an entire upload to disk while the upstream is unavailable and replays it once the server is back. I wrote that mechanism up in Decoupling Availability.

Why this shape

GoalHow the design gets it
Low idle costOnly a ~3 W Pi runs 24/7; the power-hungry box is off by default.
Always reachablePublic IP, TLS and routing live on the always-on Pi.
Graceful degradationnginx serves a downtime page from the Pi when the server is off.
One cert, many servicesWildcard *.srtk.in via DNS-01.
Survives a changing IPCron'd Cloudflare DDNS for apex + wildcard.
Small attack surfaceUnknown hosts get 444; the token is single-scoped.

The whole thing is deliberately boring: a cheap computer that never turns off, a bigger one that mostly does, and an nginx config that makes the gap invisible.