Software Engineer
Loading posts...
A visual deep dive into my homelab setup spanning three VPSes, a Tailscale mesh, Mullvad VPN tunneling, and 30+ containers working together.

What started as a 'let me try rendering OpenStreetMap data' turned into a full-blown parallel map rendering engine. Here's how I built a system that processes millions of geographic features and renders beautiful maps with airports (because I love planes).

I put an interactive industrial robot arm on my homepage. Here is the short version of why, and the parts that turned out to be more interesting than I expected.
Feel free to contact me at kanishksachdev@gmail.com
Four hosts. 50+ containers. One Tailscale mesh. One Mac as control plane. Diagrams first, words second.
| Metric | Count |
|---|---|
| Hosts | 4 (1 main + 3 workers) |
| Containers running | ~50 across the fleet |
| Public subdomains | 22 |
| Authentik-gated apps | 11 (9 via forwardAuth, 2 via OIDC) |
| Authentik groups | 4 (infra-admins, arr-users, jellyfin-users, transmission) |
| Compose files (hetzner) | 8 (split by stack via include:) |
| Submodules in parent repo | 4 (one per host) |
| Backup retention | 24 hourly · 7 daily · 4 weekly · 6 monthly |
| Storagebox capacity | 5 TB (3.4 TB used, mostly media) |
| Time to add a fully-gated new app | ~10 min (3 file changes + deploy) |
| Cost (real $/mo, see breakdown below) | ~$35 |
| Host | CPU | RAM | Boot disk | Data disk | Public? |
|---|---|---|---|---|---|
| hetzner | 8 vCPU Intel | 16 GB | 38 GB | 80 GB attached (docker data-root) | Yes (80/443) |
| oci | 4 vCPU ARM (Ampere A1) | 22 GB | 100 GB (expanded from 30) | (boot only) | Yes (80/443, post-Coolify) |
| transmission | 1 vCPU AMD | 2 GB | 38 GB | (boot only) | No |
| rpi | Raspberry Pi 5, 4 cores | 8 GB | 64 GB SD + 1 TB SATA (external) | n/a | No |
| Subdomain | Service | Auth | Tag |
|---|---|---|---|
auth.kanishksachdev.com | Authentik server | self | core |
traefik.kanishksachdev.com | Traefik dashboard | forwardAuth (infra-admins) | infra |
portainer.kanishksachdev.com | Portainer CE | OIDC + infra-admins policy | infra |
home.kanishksachdev.com | Homepage dashboard | Cloudflare + CrowdSec | infra |
wud.kanishksachdev.com | What's Up Docker | forwardAuth (infra-admins) | infra |
crowdsec.kanishksachdev.com | CrowdSec WebUI | forwardAuth (infra-admins) | infra |
files.kanishksachdev.com | Cloud Commander | forwardAuth (infra-admins) | infra |
grafana.kanishksachdev.com | Grafana | self | obs |
secrets.kanishksachdev.com | Infisical | self + MFA | secrets |
renovate.kanishksachdev.com | Renovate CE | Cloudflare + CrowdSec | dev |
dns.kanishksachdev.com | Technitium DNS | self | dns |
mail.kanishksachdev.com | docker-mailserver | self (IMAP) | |
email.kanishksachdev.com | Roundcube | mailserver creds | |
ntfy.kanishksachdev.com | ntfy | own ACL | |
jellyfin.kanishksachdev.com | Jellyfin | OIDC + jellyfin-users policy | media |
requests.kanishksachdev.com | Jellyseerr | self | media |
sonarr.kanishksachdev.com | Sonarr | forwardAuth (arr-users); API uses its own API-key auth | media |
radarr.kanishksachdev.com | Radarr | forwardAuth (arr-users); API uses its own API-key auth | media |
bazarr.kanishksachdev.com | Bazarr | forwardAuth (arr-users); API uses its own API-key auth | media |
prowlarr.kanishksachdev.com | Prowlarr | forwardAuth (arr-users); API uses its own API-key auth | media |
unmanic.kanishksachdev.com | Unmanic | basicauth | media |
transmission.kanishksachdev.com | Transmission (via Tailscale to other VPS) | forwardAuth (transmission) | media |
grad.kanishksachdev.com | Photoprism (grad photos) | intentionally public | media |
status.kanishksachdev.com | Kener status page | intentionally public | obs |
pocketbase.kanishksachdev.com | PocketBase | own | dev |
actual.kanishksachdev.com | Actual Budget | self | personal |
coolify.kanishksachdev.com | Coolify admin UI (on OCI) | self | paas |
*.preview.kanishksachdev.com | Coolify-deployed apps (on OCI) | depends on app | paas |
*.kanishksachdev.com points at hetzner public IP via Cloudflare proxy. *.preview.kanishksachdev.com is gray-clouded and points at OCI public IP directly (free-plan nested-wildcard SSL limitation).
Ansible is the only thing that touches all 4 hosts. It lives in ansible/ inside the parent repo. Control node is the Mac, deliberately, so we never have the circular problem of "manage hetzner from a process running on hetzner".
| Playbook | What it does | Typical use |
|---|---|---|
ping.yml | ansible.builtin.ping against every host | "Is the fleet alive?" |
discover.yml | Read-only fact gathering (kernel, docker version, disk, etc.) | First step on a new host or when debugging drift |
deploy.yml | git pull on host + render .env from Infisical + docker compose up -d + (if HEAD moved) docker image prune -af | Every push. The one playbook you actually run constantly. |
bootstrap.yml | Idempotent path from "fresh VPS" to "deploy-ready" (docker, group membership, SSH config, deploy key check) | When adding a new host |
backup.yml | Install/refresh borg-backup + borg-check systemd units + timers on hetzner | After editing the backup scripts |
restart.yml | docker compose restart for one (-e service=crowdsec) or all services on a host | When a bind-mounted config changed and compose-up doesn't pick it up |
secrets.yml | Push specific secrets to Infisical from a local file | Migrating new secrets in |
secrets-backup.yml | Export Infisical secrets to encrypted local backup | Periodic safety net |
status.yml | Fleet-wide health summary (container counts, disk, last deploy, uptime) | Quick "everything OK?" check |
crowdsec.yml | Install + configure the host-side nftables bouncer | First time setup; rare reruns |
| Role | Purpose |
|---|---|
host-bootstrap | Install docker engine + compose plugin, ensure user is in docker group, set up SSH config block routing github.com to the per-host deploy key |
infisical-env | Pull all secrets prefixed <HOST_PREFIX>_* from Infisical via Universal Auth, strip the prefix, write to the per-host .env file. Also handles arbitrary single-secret files via infisical_files: in host_vars. Runs before any compose-touching task. |
hetzner-backup | Drop the borg backup + check scripts into /etc/systemd/system/, enable timers, alert via ntfy on a private topic |
hetzner-crowdsec | Install crowdsec-firewall-bouncer-nftables on the host (separate from the containerized crowdsec engine), seeded with the bouncer key from Infisical |
hetzner-secrets-backup | Periodic dump of Infisical's state to a local file, also covered by borg |
# ansible/inventory/hosts.yml (IPs redacted)
all:
children:
homelab:
hosts:
hetzner: { ansible_host: <tailscale-ip>, ansible_user: root }
oci: { ansible_host: <tailscale-ip>, ansible_user: opc }
transmission: { ansible_host: <tailscale-ip>, ansible_user: root }
rpi: { ansible_host: <tailscale-ip>, ansible_user: kanishk }
# ansible/inventory/host_vars/hetzner.yml (excerpt)
repo_dest: /root
compose_dir: /root
env_prefix: <PREFIX>_ # all <PREFIX>_* Infisical secrets land here
env_file_path: ~/.env
infisical_files: # arbitrary single-file secrets
- secret: <PREFIX>_<SECRET_NAME>
dest: ~/.some-credentials
mode: "0600"
Each of the 4 hosts has its own host_vars/<host>.yml. That file is the contract: what user to SSH as, where the git checkout lives, where to render the .env, what secret prefix to scope to in Infisical.
become. Tasks declare become: true only when they actually need root. OCI's opc user has docker group + passwordless sudo; root users (hetzner, transmission) don't need escalation.changed_when: false on read-only tasks, HEAD-comparison gates on expensive ones (image prune).known_hosts first.result_format: yaml in ansible.cfg so playbook output is readable instead of the default Python repr.# from ~/personal/homelab-config/ansible/
ansible all -m ping # liveness
ansible-playbook playbooks/status.yml # fleet health
ansible-playbook playbooks/deploy.yml # deploy to all
ansible-playbook playbooks/deploy.yml --limit hetzner # one host
ansible-playbook playbooks/restart.yml --limit hetzner -e service=crowdsec
ansible-playbook playbooks/bootstrap.yml --limit newhost # set up a fresh host
ansible-playbook playbooks/backup.yml --limit hetzner # refresh borg units
git push actually triggers| If this dies | Lost | Still works |
|---|---|---|
| hetzner | Auth (everyone signed out), mail, dashboard, all *.kanishksachdev.com routing | OCI's Coolify-deployed apps (own DNS), transmission can still download (Tailscale outbound), rpi keeps running standalone |
| OCI | Unmanic transcode workers, secondary DNS, Coolify-deployed apps (portfolio + previews) | Everything on hetzner |
| transmission VPS | New downloads. Sonarr/Radarr UIs still load but downloads queued | Streaming existing media (jellyfin reads from storagebox), the rest of the fleet |
| rpi | Bar-app + meilisearch (low impact) | Everything else |
| Storage Box (Hetzner) | Media library, photoprism originals, AND the borg backup target | Apps that don't touch it (auth, mail, dashboard, monitoring). New writes to media fail silently in some clients |
| Cloudflare | Public access via DNS proxy. CF cert no longer presented at edge | Direct IP access still works if you know the hetzner IP. Coolify previews unaffected (gray-clouded) |
| GitHub | New deploys (ansible can't pull). Renovate stops opening PRs | Already-running services unaffected |
| Infisical | Re-render of .env files breaks (next deploy fails). Restart of services using ${VAR} interpolation fails | Existing running services keep their loaded env |
| Authentik | All gated apps return 500 from forwardAuth. OIDC redirects fail | Apps with own auth (grafana, actual, etc.) and public ones |
| Mac (control node) | Can't run ansible until I'm at another machine | Everything keeps running. Renovate keeps shipping PRs |
The single highest-impact single point of failure is the Hetzner Storage Box because it's both the media data plane AND the backup target. Top of the deferred-work list to fix.
Everything in Authentik is declarative YAML in hetzner-vps-config/authentik/blueprints/. Blueprints get discovered on a polling interval, applied automatically, status visible in the admin UI.
| App | Auth style | Gated by group |
|---|---|---|
| portainer (admin) | OIDC | infra-admins |
| portainer (login UI) | OIDC | open within OIDC, restricted by binding |
| jellyfin | OIDC | jellyfin-users |
| traefik dashboard | proxy forwardAuth | infra-admins |
| crowdsec-web-ui | proxy forwardAuth | infra-admins |
| wud | proxy forwardAuth | infra-admins |
| cloudcmd | proxy forwardAuth | infra-admins |
| sonarr / radarr / bazarr / prowlarr | proxy forwardAuth | arr-users |
| transmission | proxy forwardAuth | transmission |
| grafana / actual / infisical | own auth | n/a (app-native) |
| jellyseerr / homepage / renovate / pocketbase / roundcube / ntfy | mixed (own auth, CF + CrowdSec) | various |
| photoprism-grad / kener / mail | intentionally public | n/a |
| Item | $/mo (USD) | Notes |
|---|---|---|
| Hetzner CX-class (hetzner VPS) | ~$15 | 8 vCPU Intel, 16 GB RAM, 38 GB boot disk |
| Hetzner volume (80 GB) | ~$4 | Docker data-root |
| Hetzner Storage Box BX21 (1 TB) | ~$5 | 5 TB tier actually; media + backups |
| Hetzner CX11 (transmission VPS) | ~$5 | Small box just for the torrent client |
| Mullvad VPN | ~$5 | Paid per month, used by transmission only |
| OCI Free Tier (ARM64) | $0 | Ampere A1, 4 cores, 24 GB RAM, 200 GB storage |
| Tailscale | $0 | Free personal plan (< 100 devices) |
| Cloudflare DNS + proxy | $0 | Free plan |
Domain (kanishksachdev.com) | ~$1 | Amortized annual |
| Renovate CE | $0 | Self-hosted on hetzner |
| Authentik | $0 | Self-hosted, open-source edition |
| Let's Encrypt | $0 | |
| Infisical | $0 | Self-hosted |
| Mail (DMS + Roundcube + ntfy) | $0 | Self-hosted, no Mailgun/Postmark needed |
| Power for rpi | ~$1 | Estimated, 5W constant |
| Total | ~$45 | For 35+ services, full SSO, mail, media stack, monitoring, backups |
Comparable managed equivalents: $20 Vercel + $15 1Password + $10 Auth0 + $15 Notion + $10 Bitwarden + $30 Plex Pass + $20 ProtonMail = $120+/month easily.
These are known and intentionally not yet fixed (in priority order).
text_readonly vs conditional locking on enrollment email. Currently email field is always read-only on invite enrollment (admin must always set it). Could be made conditional with a policy.| Layer | Tool | Notes |
|---|---|---|
| Reverse proxy | Traefik v3 | Single ingress, file + docker providers |
| TLS | Let's Encrypt HTTP-01 | One cert per host |
| Auth | Authentik 2026.x | Blueprint-managed, OIDC + proxy outpost |
| Edge protection | Cloudflare proxy + CrowdSec | CF for *.kanishksachdev.com only |
| Container mgmt | Portainer CE | Remote agents on oci/transmission/rpi |
| Image freshness | WUD | Watches all 4 hosts via socket-proxy |
| Secrets | Infisical | Self-hosted, Universal Auth identities per host |
| Config | Docker Compose + Ansible | Mac as control node only |
| Dep updates | Renovate CE | Self-hosted, GitHub App, pinDigests: true |
| Logs | Loki + Promtail | docker_sd discovery, Tailscale push |
| Metrics | Prometheus + Grafana | Pull from cadvisor/node-exporter across fleet |
| Backups | Borg over SSH | Hetzner Storage Box target, hourly |
| DNS | Technitium (HA: hetzner + oci) | Tailscale-only :53 |
| Docker Mailserver + Roundcube | Self-hosted MTA, used by ntfy/authentik/etc. | |
| Storage | Hetzner Storage Box (CIFS, 5TB) | Mounted on 3/4 hosts |
| PaaS | Coolify v4 | Experimental, on OCI only |
become. Tasks declare become: true only when they actually need root.