My Self-Hosted Setup: Two Boxes, One Repo and an AI Co-Admin
- tags
- #Self-Hosted #Devops #Tailscale #Backups #Monitoring #Claude
- published
- reading time
- 12 minutes
There are a hundred “what I self-host” posts out there, and most of them are a list of apps. Mine runs a lot of the same apps, so the list isn’t the interesting part. What’s different is how it’s put together and how it’s run:
- Two machines with different jobs. A cloud droplet for everything public, a mini PC at home for everything private.
- Nothing at home is reachable from the internet. Not even Jellyfin. No port forwards at all.
- The whole setup lives in one git repo, and I run it day to day with Claude Code as a co-admin, with guardrails.
The shape of it
| me (the droplet) | gandalf (the home box) | |
|---|---|---|
| Where | DigitalOcean, Frankfurt | Under the TV |
| Hardware | 2 vCPU, 8 GB RAM, 96 GB disk | Intel N-series mini PC, 512 GB SSD + 960 GB SSD |
| OS | Ubuntu 24.04 | Ubuntu 24.04 with a desktop, it’s also the TV |
| Job | Public sites, things other people or my apps talk to | Media, photos, backups, anything that’s only for me |
| Reachable from | The internet (22/80/443 only) | My tailnet only |
The rule for where something goes is simple: if someone outside my devices needs to reach it, it goes on the droplet. Everything else stays home.
The droplet: the public half
| What | Why it’s self-hosted |
|---|---|
| This blog (Hugo) | A push to my git server deploys it |
| Gogs | My git server, including the repo this whole post is about |
| Vaultwarden | Passwords. The one I’m most careful with |
| Umami | Analytics for the blog and my mobile apps |
| GlitchTip | Crash reporting for my Android apps |
| Beszel | Server metrics and alerts for every machine I run |
| TREK | Travel planner I share with my partner |
| PrivateBin, Gokapi, chhoto-url | Paste, file sharing, short links |
| App backends | Push relay for FreqDroid, routing (OSRM) and place search (Photon) for Gas On Route |
| Small sites and side projects | A handful of static sites and Python apps |
Some things worth mentioning:
- The firewall is DigitalOcean’s cloud firewall, not ufw. Only 22, 80 and 443 get through, and nginx fronts everything else on localhost.
- fail2ban has a jail per login page, not just SSH. TREK, Gokapi and my ops dashboard each have their own filter. (TREK logs times without a zone, so it runs with
TZ=UTCto keep fail2ban able to parse them.) - App backends are locked to the app. The routing and search servers for Gas On Route return 403 unless the request carries the app’s key header. My uptime monitor checks that they do return 403, because the key can’t go in a public monitoring repo.
- AI crawlers are blocked on Gogs. That one got its own post.
The home box: the private half
gandalf is a small Intel N-series PC running Ubuntu with XFCE, plugged into the TV. It’s a media center first, so it autologins and has big launcher icons for Jellyfin, Seerr and the streaming apps.
| What | How it runs |
|---|---|
| Jellyfin | systemd, Intel Quick Sync for transcoding and for trickplay thumbnails |
| Sonarr, Radarr, Prowlarr, Bazarr | systemd |
| Seerr, Maintainerr, Cleanuparr | Docker Compose |
| LibrePhotos | Docker Compose (~14,000 photos, face recognition, semantic search) |
| Backrest | systemd, a web UI to browse and restore the off-site backups of both machines |
| nginx | One start page, one name per app |
Two things I do that I don’t see often:
Native services where the project ships them. I don’t run everything in Docker. Jellyfin and the *arr apps have good apt packages and systemd units, so they run natively. That makes Quick Sync trivial and the logs live in journalctl with everything else. Docker is for the apps that only ship as containers.
Every native service is sandboxed with a systemd drop-in. systemd-analyze security rated all of them 9.2 to 9.6, “UNSAFE”, out of the box. A drop-in per service brought them down to 3.1 to 3.9:
# /etc/systemd/system/sonarr.service.d/hardening.conf (trimmed)
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/sonarr /home/gandalf/Sonarr /home/gandalf/Downloads /home/gandalf/Deluge
PrivateDevices=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
The nice side effect: Jellyfin can’t write to the media library. It only needs to read it, so now it can only read it. Deleting goes through Sonarr and Radarr, which is where it should go anyway.
Nothing at home is exposed
For a while Jellyfin was public: two ports forwarded on the router, Let’s Encrypt over HTTP, dynamic DNS. In October I moved everything behind Tailscale and removed every port forward. I checked from the droplet that every port on my home IP is closed.
The part I like is how the names work. I wanted real HTTPS names like jellyfin.<my-domain>, not http://100.x.y.z:8096. So:
- Public DNS points at the tailnet IP.
@and*are A records for gandalf’s Tailscale address. Anyone can look them up, but a100.64.0.0/10address only routes inside my tailnet. - A wildcard certificate via the DNS challenge. Let’s Encrypt can’t reach a tailnet IP, so certbot uses the DigitalOcean DNS plugin with a token that can only touch DNS records. Because it’s one wildcard cert, the app names never show up in Certificate Transparency logs.
- nginx maps the subdomain to a local port and rejects anyone outside the tailnet:
map $app $app_port {
default "";
jellyfin 8096;
sonarr 8989;
photos 3000;
# ...
}
# Tailscale's IPv4 range and IPv6 prefix
geo $tailnet_allowed {
default 0;
100.64.0.0/10 1;
fd7a:115c:a1e0::/48 1;
}
server {
listen 443 ssl;
server_name ~^(?<app>[a-z]+)\.example\.eu$;
if ($tailnet_allowed = 0) { return 403; }
location / {
proxy_pass http://127.0.0.1:$app_port;
}
}
Adding an app is one line in the map. An unknown name gets a 404 that points to the start page.
Two gotchas from this:
- NextDNS blocks it by default. Its DNS rebinding protection answers
0.0.0.0for public names that resolve to private or CGNAT addresses. The domain has to be on the allowlist. - Pass the real client IP, not
$proxy_add_x_forwarded_for. Sonarr and Radarr skip their login for local addresses. If the proxy makes every request look like127.0.0.1, the login disappears.
At home, devices without Tailscale (the TV, a Chromecast) still use the LAN address. ufw allows Jellyfin from the LAN and nothing else.
The two boxes help each other
The droplet and gandalf each do a favour for the other, through SSH keys that can do exactly one thing.
My ISP blocks some sites at the TLS level. They work fine from the droplet, so gandalf keeps an ssh -D SOCKS tunnel to it, and only the requests that need it go through. The key on the droplet side can’t open a shell, can’t reach the droplet’s own services and can only connect out on 80 and 443:
restrict,port-forwarding,permitopen="*:80",permitopen="*:443",command="/usr/sbin/nologin" ssh-ed25519 AAAA... gandalf-tunnel
gandalf pulls a copy of the password vault every night. Another key, another user, a forced command. Whatever you ask it to run, it streams the latest Vaultwarden snapshot and nothing else:
restrict,command="/usr/local/sbin/vaultwarden-backup-serve.sh" ssh-ed25519 AAAA... gandalf-vaultwarden-backup
So the vault lives in three places: on the droplet, at home, and in the off-site backup.
Backups: only what I can’t get back
The rule is back up what can’t be downloaded again. Photos, configs, databases, the vault. Not movies or TV.
| From | What | Where |
|---|---|---|
| gandalf | Photos (116 GB), a nightly config backup (every app’s DB, /etc, compose files, a LibrePhotos pg_dumpall), the vault copies |
restic → Hetzner Storage Box |
| me | DB dumps, Gogs, Vaultwarden, every app’s data, /etc, the sites |
restic → the same Storage Box, separate sub-account |
| me | Everything, as a whole-machine image | DigitalOcean droplet backups |
A few details that made a difference:
- The Storage Box is a 1 TB BX11 for about €3.20 a month. restic over SFTP, keeping 7 daily, 4 weekly and 6 monthly snapshots, with a
checkthat reads back 1% of the data every Sunday. - Uploads are throttled during the day. 1 MB/s from 08:00 to midnight, unlimited at night, so someone watching Jellyfin remotely isn’t fighting the backup. restic can’t change its limit mid-run, so a timer restarts a long run at the boundaries and it picks up where it left off.
- Config archives use
gzip --rsyncable. Without it, a tiny change anywhere changes the whole compressed file and restic uploads all of it again. - Backrest gives me a UI over both repos. Browsing a snapshot and restoring one file is a few clicks instead of a restic session over SSH.
Monitoring: three layers and my own dashboard
| Layer | Catches | Why it’s separate |
|---|---|---|
| Upptime | A public site down | Runs on GitHub, so it still works if the droplet dies |
| Beszel | A machine down, disk, memory, temperature | Metrics and history for every machine, including my trading bot droplets |
| ops (my own) | App-level problems | Runs each host’s health.py every 5 minutes |
The health.py scripts are where most of the value is. Each host has one, in the repo, and it checks what actually matters there: services and containers, disks and SMART, certificate expiry, that backups are fresh, that every site answers through nginx, that the *arr apps are healthy, that no episode has been missing for two days.
ops is a small app I wrote that collects those reports. It shows the history, and sends Telegram messages only when something changes and stays changed for two reports in a row. From Telegram I can also restart an allowlisted service on gandalf with a confirm button. The allowlist lives on gandalf, so the dashboard can’t ask for anything else.
Getting the alerts quiet took tuning. Public indexers drop out for 10 to 60 minutes all the time, and each outage used to alert three times (Sonarr, Radarr and Prowlarr all report it). Now it’s one check that only warns after Prowlarr says it’s been failing for six hours.
One repo, and Claude Code as co-admin
This is the part that’s most different from other setups I’ve read about.
Everything lives in a private repo on my own Gogs: the configs for my Mac, and a folder per server. Each server folder has a README with what runs where, how to deploy it, gotchas, and a History section that logs every change with its date and the reason. The health.py scripts, nginx configs, systemd units and hardening drop-ins live there too, and get deployed from there.
I do most of the admin work through Claude Code in that repo. “gandalf’s root disk is at 87%, find out why.” “Upgrade the servers.” “Why didn’t this episode download?” It reads the READMEs, SSHes in, figures it out, fixes it, and updates the README and History in the same change. That’s the important part: the documentation stays current because the agent that makes the change also writes it down.
Handing an AI SSH access to the box with my password vault needs guardrails, so:
- Reading is free, changing asks. That rule is in the repo’s
CLAUDE.md, and it’s also enforced by aPreToolUsehook. Anyssh,scporrsynccommand that matches a state-changing pattern (systemctl restart,docker compose up,apt install,rm -rf,reboot…) has to be confirmed by me, whatever permission mode the session is in. - Routine maintenance is a skill.
server-upgradeknows the order: config backup, apt upgrades, Docker image updates (comparing running digests against the registry), reboot one host at a time, then check everything came back. A full pass on both machines is one prompt and a few confirmations. - Secrets never go in the repo. The READMEs say where a secret lives (a root-only file, Vaultwarden), never what it is.
The honest trade-off: gandalf has passwordless sudo for the admin user, because an agent can’t type a sudo password. Anyone with that SSH key is root on the home box. I’m fine with that for a media center behind Tailscale. I wouldn’t be for the droplet, which is also why it gets more care.
Things that broke
The fun part of a History log is that it remembers the failures. Some favourites:
pgautoupgrade:lateststarted an empty database. LibrePhotos’ Postgres image moved to PostgreSQL 18, which uses a different data path. With my bind mount, it silently started a fresh, empty database, and LibrePhotos greeted me with a public first-run setup page. The image is now pinned to17-alpine.localhostbecame::1after a reboot. nginx proxied tolocalhost:3000, Docker only listened on IPv4, and after a power-blip reboot nginx resolved to IPv6 and returned 502 for 15 hours. The containers were up, so every health check was green. Nowproxy_passuses127.0.0.1, andhealth.pyrequests every site through nginx instead of just checking containers.- The Wi-Fi went quiet without disconnecting. gandalf stayed associated with the router but stopped passing traffic. Power saving wasn’t it, and turning off TX aggregation in
iwlwifihelped. Just in case, a watchdog pings the gateway every 30 seconds, reconnects after 2 minutes and reloads the driver after 5. - Marking things as watched counts as watching them. Maintainerr deletes watched TV after a while, counting from the last play. I marked a batch as watched, which reset the clock, and the root disk hit 87%.
tarexit code 1 aborted a whole night’s backup. It means “a file changed while I read it”, which Jellyfin’s data dir always does. The backup script now treats it as a warning.- Fake pre-air releases. A “release” of an episode that hasn’t aired yet is always fake, and one of them took Deluge down. Sonarr has no “don’t grab before the air date” setting, so a small timer keeps unaired episodes unmonitored and monitors them again at air time.
And one bit of glue I’m happy with: a show I follow is only published on YouTube, so no indexer will ever have it. A timer asks Sonarr which episodes are missing, downloads them with yt-dlp and imports them through Sonarr’s API, so they land in the library like any other episode.
What’s next
- The TV library lives on the system disk. Deliberately, so TV and movies fill different disks, but it means a full disk takes the OS down with it. It probably wants its own drive.
- Per-app access on the tailnet. Every app shares port 443, so Tailscale’s grants can’t give someone Jellyfin without also giving them Sonarr. That needs nginx to check identity per app.
- Restart buttons for the droplet. ops can only restart services on gandalf so far.
If there’s one thing to take from this, it’s not an app. It’s write down every change, with the reason, next to the config. Six months from now that History section is the only thing that knows why the Postgres image is pinned. Having an agent do the writing just means it actually happens.