The 35-Watt Roommate (Part 2): The Cloud in a Cabinet
- 1.The 35-Watt Roommate (Part 1): From Memory Hunger to Mini Powerhouse
- 2.The 35-Watt Roommate (Part 2): The Cloud in a Cabinet
- 3.The 35-Watt Roommate (Part 3): Middle-earth and the Digital Infarction
- 4.The 35-Watt Roommate (Part 4): Docker Cleanup and Surgical Data Recovery
- 5.The 35-Watt Roommate (Part 5): Monitoring, or 'Why is the internet constantly asking for my .php files?'
- 6.The 35-Watt Roommate (Part 6): Ads? Never Heard of Them.
- 7.The 35-Watt Roommate (Part 7): Goodbye Fritz!Box, Hello Ubiquiti
- 8.The 35-Watt Roommate (Part 8): Umami Analytics, or How a Password with Special Characters Broke Everything
In Part 1 I covered the hardware: how the Lenovo M920q replaced my overwhelmed Synology as a compute node. The hardware is in place and Proxmox is humming. This part covers how the services reach the internet without turning the router into Swiss cheese.
Infrastructure as code: everything in Git
Clicking containers together by hand is not for me. If I break something, I want to rebuild it with a single command. My setup: a Git repository with one YAML file per service.
Instead of dumping everything into one giant docker-compose.yml, I built my services as modular Portainer stacks:
-
gitea.yml My private code home, including a Gitea Runner for CI/CD.
-
jellyfin.yml Media streaming for after work. Movies live on the Synology, streaming runs on the M920q. For this to work, the NAS media volume needs to be mounted on the Docker host via NFS or SMB. In my case, a simple entry in
/etc/fstab://192.168.1.x/media /mnt/nas-media cifs credentials=/etc/nas-credentials,uid=1000,gid=1000 0 0Jellyfin then accesses
/mnt/nas-mediathrough a bind mount. -
vaultwarden.yml Password manager for all devices. More on this below.
-
web.yml Traefik as reverse proxy for my websites (blog, portfolio, howtolosemoneyfast etc.).
-
tunnel.yml The Cloudflare Tunnel that ties everything together.
New service? Create a YAML file, deploy it as a stack in Portainer, done. Everything broken? git pull, redeploy the stacks, back online.
Cloudflare Tunnels: no open ports, still reachable
I no longer open ports 80 and 443 on the router, set up DynDNS, and hope nobody rattles the front door.
The Cloudflare Tunnel (cloudflared) establishes an encrypted connection from the inside out to Cloudflare. No inbound port needs to be opened. Cloudflare handles TLS termination, DDoS protection, and DNS.
Three things make this valuable for my setup:
- No port forwarding. My router stays completely locked down and port scanners hit nothing.
- Zero Trust via Cloudflare Access. Before anyone sees the login page of Jellyfin or Gitea, they have to pass an email verification, and only approved addresses get through.
- One subdomain per service. In the Cloudflare dashboard you create a subdomain for each service and point it to the internal port, for example
jellyfin.example.com→http://jellyfin:8096orgit.example.com→http://gitea:3000. Each subdomain gets its own access rules, so you can control who reaches each service.
The architecture
Here is the path from the browser to each service:
The cloudflared container connects to Cloudflare automatically on startup. All services sit on the same Docker network as cloudflared and are reachable directly. Only the static websites (blog, portfolio) have Traefik in front as a reverse proxy, because multiple domains need to be routed to different Nginx containers. The Synology remains a pure storage node: movies and backups.
Important: local traffic should not go through the Cloudflare Tunnel. When the TV in the living room streams a movie, it makes no sense to send the data to Cloudflare and back into your own network. That costs latency and bandwidth. Instead, TV and notebook connect directly to Jellyfin via IP (port 8096). The tunnel is only for access from outside.
Vaultwarden Instead of KeePass + Google Drive
My previous setup: a KeePass database on Google Drive, the same .kdbx on every device. Works fine until you change a password on the laptop while the phone still has the old version open. Save, sync conflict, two files. Then you get to merge by hand and hope nothing is lost. That happened often enough that I got tired of it.
Vaultwarden is a Rust reimplementation of the Bitwarden server API. You use the official Bitwarden clients and browser extensions, point the server URL to your own domain, and log in. The clients cannot tell the difference from hosted Bitwarden, but the data stays with me.
Sync works across all devices, autofill in browsers and on mobile just works, and sharing passwords with the family is handled through organizations. The container uses less than 100 MB of RAM.
For me this was the service that justified the whole homelab effort the fastest. No more Google Drive in the chain, no more broken .kdbx files.
Deployment via shared volumes
One detail I am quite happy with: how the websites go from git push to live without any file copying or rsync.
The Gitea Runner has access to specific Docker volumes listed as valid_volumes in its config, for example blog-astro-content-prd or howtolosemoney-content-prd. When a CI pipeline runs, the runner builds the static site and writes the output directly into the matching volume. The Nginx containers in web.yml mount exactly these volumes as read-only:
# web.yml (excerpt)
blog-astro-web-prd:
image: nginx:alpine
volumes:
- blog-astro-content-prd:/usr/share/nginx/html:ro
# gitea.yml (runner config excerpt)
container:
valid_volumes:
- "blog-astro-content-prd"
- "howtolosemoney-content-prd"
- "portfoliomanager-content-prd"
Git push, runner builds, result lands in the volume, Nginx serves it. No SCP, no webhook, no second deploy step. The Nginx containers do not even notice anything changed because they just read from the filesystem.
FritzBox, IP changes and the bypass rule
Cloudflare Access protects my services with email verification. But from my own network I do not want to go through that every time. For that there is a Zero Trust bypass policy that allows my public IP through. Problem: the FritzBox gets a new IP on every forced reconnect.
The solution is a webhook. In the FritzBox under “Internet > Permits > DynDNS” there is an update URL pointing to my nas-manager-hooks container. When the IP changes, the FritzBox calls the webhook which triggers a Python script that:
- Fetches the current IPv4 and IPv6 via external services (ipify, ident.me)
- Calculates the /64 network from the IPv6 address
- Updates the Cloudflare Zero Trust bypass policy via API with the new IPs
The script looks up the IPs externally because the FritzBox passes the IPv4 in the DynDNS callback but no IPv6 address. So the script fetches IPv6 itself via api6.ipify.org with v6.ident.me as fallback. If the lookup fails it retries up to five times with exponential backoff.
So when the IP changes, the FritzBox reports in, the script runs and the bypass policy is up to date, without a cron job or polling.
Real-world performance
CPU usage rarely climbs above 30%. Thanks to the fiber connection, my friends cannot tell whether the server lives in my cabinet or in a data center.
Some numbers from daily operation:
| Metric | Value |
|---|---|
| RAM usage (all containers) | ~8 GB |
| Idle power consumption | ~10 W |
| Power consumption under load | ~35 W |
| Active Docker containers | 9 |
What’s next in Part 3
Mysterious network outages, always right when a movie should be playing in the evening. In Part 3 I dig into the network logs to find out whether the M920q is really a good roommate or if it is secretly killing the Wi-Fi.