My Self-Hosted Setup: Two Boxes, One Repo and an AI Co-Admin

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=UTC to 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:

  1. Public DNS points at the tailnet IP. @ and * are A records for gandalf’s Tailscale address. Anyone can look them up, but a 100.64.0.0/10 address only routes inside my tailnet.
  2. 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.
  3. 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.0 for 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 like 127.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 check that 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 a PreToolUse hook. Any ssh, scp or rsync command 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-upgrade knows 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:latest started 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 to 17-alpine.
  • localhost became ::1 after a reboot. nginx proxied to localhost: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. Now proxy_pass uses 127.0.0.1, and health.py requests 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 iwlwifi helped. 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%.
  • tar exit 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.