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 about each backend as a named upstream pointing at the server's LAN address, then gives each subdomain its own server {} block. The Pi itself only directly serves this website (a small Flask app on :5000); everything else is forwarded 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

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, for example) gets the connection closed with no response at all, instead of leaking a default page.

Failing over gracefully when the server is off

This is the part that makes the on-demand model actually usable. When the beefy server is powered down, its upstreams are unreachable. Without handling, every subdomain would throw a raw 502. Instead, each proxied service is configured to catch the failure and serve a friendly "this is currently 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
    }
}

The = /fallback form rewrites the response so the client gets a clean page served by the Flask app on the Pi, which is always up. So when the server is asleep, visiting photos.srtk.in shows a tidy downtime page instead of a browser error, and the moment I power the server back on, nginx starts proxying to it again with no further action.

For uploads specifically, the proxy buffering on the Pi does something even nicer: it can absorb an entire upload to disk while the upstream is unavailable and replay it once the server is back. I wrote that mechanism up in detail 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.