The 35-Watt Roommate (Part 2): The Cloud in a Cabinet

In Part 1 I covered the hardware: how the Lenovo M920q replaced my overwhelmed Synology as a compute node. The hardware is in place, Proxmox is humming. Now for the real question: how do 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 0

    Jellyfin then accesses /mnt/nas-media through 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

The old way: open ports 80 and 443 on the router, set up DynDNS, and hope nobody rattles the front door. Today that is no longer necessary.

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.

Two things that make this especially valuable for my setup:

  1. No port forwarding. My router stays completely locked down. Port scanners hit nothing.
  2. Zero Trust via Cloudflare Access. Before anyone sees the login page of Jellyfin or Gitea, they must pass an email verification. Only approved email addresses get through.
  3. 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.comhttp://jellyfin:8096, git.example.comhttp://gitea:3000. Each subdomain gets its own access rules. This keeps everything cleanly separated and lets you control who can reach each service individually.

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. The trick: 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:

  1. Fetches the current IPv4 and IPv6 via external services (ipify, ident.me)
  2. Calculates the /64 network from the IPv6 address
  3. Updates the Cloudflare Zero Trust bypass policy via API with the new IPs

Why fetch externally? 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.

The result: IP changes, FritzBox reports in, script runs, bypass policy is up to date. No cron job, no polling.

Real-World Performance CPU usage during that: rarely 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:

MetricValue
RAM usage (all containers)~8 GB
Idle power consumption~10 W
Power consumption under load~35 W
Active Docker containers9

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.