Self-Hosting on a Raspberry Pi
Everything I hosted at home, fronted by a 3 W Raspberry Pi.
Contents
At the time of writing, everything on srtk.in ran on hardware in my house: this site, my photos, my git server, my passwords, my files. There was no cloud VM involved.
The whole setup is built around one idea. A tiny always-on machine should be the only thing that has to stay up. Everything that needs real power runs on a second machine that I can switch off whenever I’m not using it and the outside world shouldn’t be able to tell the difference.
This post covers each piece in the order a request meets it: DNS, TLS, routing and what happens when the big machine is off. If you want to build something similar, each section explains the idea first and then shows how I did it.
The two machines#
There are exactly two boxes.
- The Raspberry Pi draws a few watts, runs 24/7 and does almost no real work. It holds the public IP, terminates TLS and forwards requests. It’s the only machine that’s always on.
- The server is a much bigger machine on the LAN at
192.168.1.217. It runs the heavy services: Immich for photos, Forgejo for git, Vaultwarden for passwords, a file server and a WebDAV endpoint. It can be powered down to save electricity and nothing public-facing breaks when it is.
The Pi is cheap enough to run that leaving it on costs next to nothing and it has no spinning disks or hungry GPU to feed. The server is where the electricity goes, so the server is the one that gets switched off.
Step 1: getting traffic home with dynamic DNS#
The problem#
A domain name needs to point at an IP address. My home connection has a dynamic public IP: my ISP can change it whenever it wants and when it does, every DNS record pointing at the old address breaks.
The fix#
DNS for the domain lives on Cloudflare, which has an API for editing records. A small Python script runs from cron, checks the current public IP and updates the records if it changed. It maintains two records:
srtk.in, the apex domain.*.srtk.in, a wildcard, so every subdomain (git.,photos.and so on) follows the same IP automatically without needing its own record.
The core of the script is short:
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
A few details make it behave well:
- It only writes when something changed. Most runs are a single read and nothing else.
- The TTL is 60 seconds. TTL is how long other DNS servers may cache the answer. A short one means a new IP spreads within about a minute instead of hours.
- The records aren’t proxied through Cloudflare (“grey cloud” in their dashboard). Traffic comes straight to my home router, which forwards ports 80 and 443 to the Pi.
The Cloudflare API token in the real script has exactly one permission: Zone → DNS → Edit, on this one zone. If it ever leaked, someone could change my DNS records, but they couldn’t touch anything else in the account. Scope every token to the smallest thing that works.
Step 2: one certificate for every subdomain#
Why a wildcard#
Every service is a subdomain, so instead of getting a separate TLS certificate for each one, I use a single wildcard certificate for *.srtk.in (plus the apex) from Let’s Encrypt.
How you prove you own the domain#
Before Let’s Encrypt issues a certificate, it asks you to prove you control the domain. There are two common ways:
| Challenge | How it works | Can issue wildcards? |
|---|---|---|
| HTTP-01 | You serve a token at http://<domain>/.well-known/acme-challenge/ |
No |
| DNS-01 | You publish a token in a TXT record under _acme-challenge.<domain> |
Yes |
Wildcards can only be issued through DNS-01. That works out nicely here, because the DNS is already on Cloudflare and already has an API. certbot’s Cloudflare plugin creates the TXT record, waits for Let’s Encrypt to check it and removes it again, with no web server involved at all:
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d srtk.in -d '*.srtk.in'
nginx then loads the one certificate for every site:
ssl_certificate /etc/letsencrypt/live/srtk.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/srtk.in/privkey.pem;
Step 3: routing every subdomain from one Pi#
How one IP serves many sites#
Every request for every subdomain arrives at the same IP and the same port 443. nginx tells them apart by the Host header, the part of an HTTP request that says which name the client asked for. Each subdomain gets its own server {} block matching its name and each block forwards to the right service.
nginx on the Pi knows each backend as a named upstream on the server’s LAN address. The only thing the Pi serves itself is this site, a small Flask app on port 5000:
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
Dropping everything else#
The return 444 on the default HTTPS server is deliberate. 444 isn’t a real HTTP status. It’s nginx’s instruction to close the connection without sending anything back.
Any public IP gets a constant stream of scanners that connect to the raw IP, or send a Host header for some domain that isn’t mine. Those requests don’t match any server_name, so they land on the default server and get nothing: no error page, no nginx version, no hint about what else lives on the machine.
Step 4: failing gracefully when the server is off#
This is what makes switching the server off practical. When it’s powered down, its upstreams are unreachable. Without any handling, every subdomain would throw a raw 502 Bad Gateway.
Instead, each proxied service catches the failure and serves an offline page from the Pi:
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
}
}
error_page ... = /fallback tells nginx that if the upstream fails with any of those errors, it should handle the request internally at /fallback instead and return that response. /fallback points at the Flask app on the Pi, which serves a small “this service is sleeping” page. When I power the server back on, nginx goes back to proxying to it with no change needed.
Uploads get something better still. The Pi’s proxy buffering can absorb most of an upload to disk while the server is down and send it on once the server is back. I wrote that mechanism up separately in Hiding Downtime Behind a Proxy.
Why it’s shaped like this#
| Goal | How the design gets it |
|---|---|
| Low idle power | Only a ~3 W Pi runs all the time. The power-hungry box is off by default. |
| Always reachable | The public IP, TLS and routing all live on the always-on Pi. |
| Graceful degradation | nginx serves an offline page from the Pi when the server is off. |
| One certificate | A wildcard *.srtk.in via the DNS-01 challenge. |
| Survives IP changes | A cron job keeps the apex and wildcard records pointed at home. |
| Small attack surface | Unknown hosts get 444. The API token can only edit DNS for one zone. |
It’s a deliberately boring setup: a cheap computer that never turns off, a bigger one that mostly does and an nginx config that hides the gap between them.