Self-Hosting on a Raspberry Pi
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 Raspberry Pi sips a few watts, runs 24/7, and does almost no real work. It terminates TLS, holds the public IP, and proxies requests. This is the only machine that is always on.
- The server, a far beefier box (
192.168.1.217on the LAN) that actually runs the heavy services: Immich, Forgejo, Vaultwarden, a file server, a WebDAV endpoint. It can be powered down on demand to save electricity, and nothing public-facing dies when it is.
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.
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
| Goal | How the design gets it |
|---|---|
| Low idle cost | Only a ~3 W Pi runs 24/7; the power-hungry box is off by default. |
| Always reachable | Public IP, TLS and routing live on the always-on Pi. |
| Graceful degradation | nginx serves a downtime page from the Pi when the server is off. |
| One cert, many services | Wildcard *.srtk.in via DNS-01. |
| Survives a changing IP | Cron'd Cloudflare DDNS for apex + wildcard. |
| Small attack surface | Unknown 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.