How traffic actually gets there

Networking

Four ideas, then two ordinary requests traced hop by hop — then the two things that are genuinely confusing: how the downloader is sealed inside a VPN, and why nobody on the internet can start a connection to your house.

01

Four ideas, then the traces

Everything below is built from these. None are complicated once the metaphor lands.

Idea one

An address plus a port

The address says which machine; the port says which program on it. Street address and apartment number. One machine runs dozens of programs, each listening on its own port, all sharing one address.

192.168.1.10:8096
└ the machine ┘ └ Jellyfin ┘

Idea two

A tunnel — Tailscale

Your Mac and homie each hold an outbound connection to Tailscale’s coordination service. Because both reached out, they can be introduced and then talk directly, encrypted end to end. No incoming port ever opens on your router, because nothing ever knocks on it from outside.

homie 100.64.0.10
the Mac 100.64.0.20

Idea three

A reverse proxy — Caddy

A receptionist for the building. Visitors only ever speak to reception; reception walks the message to the right office and brings the answer back. The offices never talk to the public. One desk, one set of rules, one place that handles security — instead of every service improvising its own.

you → caddy:443 → jellyfin:8096
you → caddy:8443 → syncthing:8384

Idea four

A certificate — HTTPS

Two jobs at once. It scrambles the conversation so nobody in between can read it, and it proves identity — that the server answering really is the one you asked for. The padlock means both happened. A certificate is a signed statement from an authority your device already trusts.

issuer: Let’s Encrypt
subject: homie.tailnet-name.ts.net

Why the proxy earns its place

Without Caddy, every service handles its own encryption, its own certificate renewal and its own exposure — and each does it differently, badly, or not at all. With Caddy, encryption is solved once, at the door. Behind it, services speak plain HTTP to each other on a private network no outsider can reach, which is fine, because the dangerous part of the journey already ended at reception.

That prediction has been tested twice since. Adding the downloader was one Caddy block and one more port on the existing certificate; adding n8n was the same again. Neither was a fresh security decision.

02

Trace one — school files, from the Mac

You are at a coffee shop and want a file from ~/data/school-work. You type ssh admin@homie.tailnet-name.ts.net.

The surprise: neither Caddy nor Docker is involved at any point. Worth following, because it clarifies what each is actually for.

  1. On your Mac — the name resolves privately

    Tailscale intercepts the lookup for homie.tailnet-name.ts.net and answers 100.64.0.10 itself. This name does not exist on the public internet; ask any normal DNS server and you get nothing.

  2. Between the machines — a direct encrypted path is negotiated

    Both already hold outbound connections to Tailscale’s coordination service, so they can be introduced and then punch a path straight to each other. Everything is encrypted with WireGuard before it leaves the Mac. The coffee shop, your ISP and Tailscale itself carry sealed traffic they cannot read. If no direct path can be found, an encrypted relay is used instead — slower, still private.

  3. Arriving at homie — it lands on tailscale0

    homie has two interfaces: wlp4s0 facing the house and tailscale0 facing the tailnet. This traffic arrives on tailscale0, is decrypted, and is addressed to port 22.

  4. On homie — SSH answers, and you get the files

    ufw permits SSH on every interface, so it passes; your identity is proven with a key rather than a password. sshd runs on the host, not in a container — so no Docker networking, no port publishing, no Caddy. ~/data/school-work is an ordinary directory on the disk. Copy with scp or sftp and you are done.

  5. Meanwhile, separately — Syncthing is watching that same directory

    That folder is also shared into the Syncthing container. Anything you drop there gets picked up and synced to your other devices — not because SSH told it to, but because it is the same folder on disk viewed from two places.

The takeaway

Caddy handles web traffic. SSH is not web traffic, so it goes straight to the host and skips the proxy entirely. Tailscale is what makes the machine reachable at all; Caddy is what makes browser traffic to it encrypted and tidy. Different problems, easy to confuse.

03

Trace two — a download, then the living room TV

tailnet LAN VPN tunnel

One file’s whole life: requested from a laptop over the tunnel, downloaded through a VPN, filed onto disk, then played on a TV that reaches the server a completely different way.

  1. You open the downloader from your laptop

    Its control panel sits behind Caddy on the tailnet at port 8444, exactly like Syncthing’s — reachable by you from anywhere, invisible to everyone in the house. From the house WiFi the address returns nothing at all.

    It needed one thing Jellyfin and Syncthing did not. qBittorrent checks that the port in the browser’s Host header matches the port it is itself listening on, and rejects the request when they differ — which is exactly what a proxy on a different port causes. So Caddy rewrites three headers (Host, Origin, Referer) to http://homie.tailnet-name.ts.net:8080, the origin qBittorrent believes it serves. Both of its protections stay switched on. Your browser is still on HTTPS at 8444; only the internal hop is rewritten.

  2. The download leaves through a VPN, not your home connection

    Every packet qBittorrent sends leaves through a WireGuard tunnel to a NordVPN server, and the address every peer in the swarm records is Nord’s, never the house’s. §04 is how that is enforced.

  3. The finished file lands in the media folder, correctly named

    The downloader writes into /mnt/t7/… through a writable bind. Jellyfin’s view of that same folder is read-only — one directory, two containers, different permissions, deliberately.

    Naming decides whether any of this works. A release arrives as Harbor.Town.S02E03.1080p.WEB-DL.x265-GROUP.mkv, which Jellyfin cannot identify. On completion qBittorrent runs a script that rewrites it to Harbor Town (2022) - S02E03.mkv and moves it into place — and because downloads and library share one drive, that move is an instant rename that copies no bytes.

    The script refuses to guess. A release it cannot confidently identify is left exactly where it is, with the reason written to config/qbittorrent/qbt-file.log. A file that waits is a minute’s work; a file misnamed into the wrong show is not.

  4. Jellyfin notices on its next scan

    It works out what the file is and fetches artwork and details from the internet. This is Jellyfin reaching out; nothing reached in.

  5. The TV connects straight to homie — no proxy, no tunnel

    The TV opens the Jellyfin app and connects to 192.168.1.10:8096 over the house WiFi. It is not on the tailnet and does not go through Caddy. This is the shared door, and it is plain HTTP — putting the TV behind Caddy would mean installing a certificate on it and on every roommate device.

  6. Docker hands the connection to the container

    Jellyfin does not sit on the network directly; it lives on a private Docker network, and Docker forwards anything arriving on the host’s port 8096 into the container. That forwarding is also why the firewall cannot be trusted to protect container ports, and why each port is pinned to a specific address instead.

  7. Playback: ship it as-is, or convert it on the fly

    If the TV can handle the file’s format, Jellyfin ships the bytes untouched — cheap, and the best outcome. If not, it converts while playing, on the laptop’s built-in video engine rather than the CPU. On a four-thread machine what matters is that the work lands on dedicated silicon instead of eating every core. homie-check reports which one a live playback is actually using.

  8. And at the same time — you can watch the same library over the tunnel

    From outside the house you reach Jellyfin at https://homie.tailnet-name.ts.net — through the tunnel, through Caddy, fully encrypted. Same server, same library, different door. The two paths coexist without either knowing about the other.

04

How the downloader is sealed in

qBittorrent has no network of its own. It shares Gluetun’s entirely — one Linux network namespace, so the only interfaces it can see are loopback and tun0, the mouth of a WireGuard tunnel to a NordVPN server.

shared network namespace gluetun holds the tunnel qBittorrent no interfaces lo · tun0 10.5.0.2 — and nothing else tun0 · WireGuard NordVPN exit US · P2P server the swarm sees Nord replies to private ranges only Caddy · the LAN There is no third arrow. If the tunnel dies, nothing leaves — not a fallback, an absence.
The privacy mechanism is a containment change, not a setting. qBittorrent cannot leak because there is nothing to leak through.

This is a kill switch by construction. If the VPN drops, qBittorrent does not fall back to the ordinary connection — it has no interface to fall back to. Tested by stopping Gluetun with a download running: traffic stopped, and so did DNS.

The mechanism is not the one you would guess

It does not work by replacing the default route. Inside the namespace the default route still points at the Docker bridge. What diverts internet-bound traffic is a policy rule at priority 101 matching WireGuard’s firewall mark. You can watch the kernel decide:

$ docker exec gluetun ip route get 1.1.1.1
1.1.1.1 dev tun0 table 51820 src 10.5.0.2

$ docker exec gluetun ip route get 172.18.0.2
172.18.0.2 dev eth0 src 172.18.0.5

Two other details cost time and are worth keeping: Gluetun names the interface tun0 even on WireGuard, not wg0; and Nord permits P2P only on certain servers, so SERVER_CATEGORIES=P2P constrains every pick including reconnects. SERVER_COUNTRIES=United States was added after a reconnect landed in Edinburgh and every peer round trip crossed the Atlantic.

The consequence nobody warns you about

The kill switch drops anything leaving that is not going out the tunnel — and qBittorrent’s reply to Caddy is a packet leaving for a Docker address, not the tunnel. So the request arrives, the answer is dropped, and the page just hangs, looking exactly like a broken VPN.

The fix is to name the private ranges as allowed exceptions: FIREWALL_OUTBOUND_SUBNETS=172.18.0.0/16,100.64.0.0/10,192.168.1.0/24 — the Docker bridge, the tailnet and the LAN. None can reach the public internet, so nothing is weakened; a real internet destination still has exactly one road.

Restarting Gluetun orphans qBittorrent — always both, always in order

A namespace binding is fixed when a container is created. Recreate Gluetun and qBittorrent is left in an empty namespace holding only loopback: alive, looking fine, connected to nothing. After any Gluetun change, recreate both — Operating manual §08 has the commands, and homie-check catches it if you forget.

05

Why nobody can call you

This is the part that stays fuzzy longest, so it gets its own section. The thing to hold on to: who starts the conversation determines everything.

Your router runs NAT. It has one public address and a table of ongoing conversations. When homie sends a packet out, the router writes a row — this inside address and port is talking to that outside address and port — rewrites the source to the public IP, and sends it. Replies matching that row get translated back and delivered. Replies matching no row have no destination inside the house, so they are dropped: the router cannot know which of your devices they were for.

A · you start it — works, needs no configuration qBittorrent :49312 router writes a NAT row .170:49312 → 203.0.113.5:6881 a peer 203.0.113.5 data flows BOTH ways on this one connection — you upload here too B · a stranger starts it — dropped, and that is the default everywhere a peer wants to connect router looks for a matching row finds none — which device was it for? ✕ no forwarded port, so the packet is discarded. Downloads are unaffected. C · a provider-side forward — why it “bypasses” your router a peer dials 45.x.x.x:54321 VPN server holds the public port pushes it down the tunnel you opened qBittorrent inside the namespace your router sees only more bytes on a conversation it already approved
Panel C is why a provider-side forward is a genuine solution rather than a workaround: it gives you a reachable public port without punching a hole in your own perimeter. NordVPN does not offer one.

The asymmetry is the whole story. Outbound connections carry data in both directions, which is why downloading works perfectly with no forwarded port. What you lose without one is only the ability for peers to initiate — so you upload to fewer of them than you otherwise could.

OptionWhat happens
Accept it chosen Download speeds unaffected. Upload goes only to peers you connected to. qBittorrent shows a red “not connectable” dot — that is this, not a fault.
Forward 6881 on the router Pointless now the VPN is in place: peers know us by the Nord address and never dial the house. Before the VPN it would have worked — and would have advertised the home address to the swarm more loudly.
Change provider ProtonVPN and AirVPN forward a port through the tunnel, and Gluetun can ask for one automatically. Worth it only for a ratio-enforcing private tracker.
UPnP / NAT-PMP qBittorrent asking the router to open a port by itself. Same dead end as row two, and off for good reasons anyway.
Do not combine rows one and two

Running the VPN and a router forward, hoping for both, gives the worst outcome: qBittorrent would have to announce the home address to the swarm for the forward to be useful, handing out exactly the address the whole exercise exists to hide.

06

Who can reach what

The design in one table. Every row was verified by connecting from both sides; bin/homie-check re-tests them on every run.

You, anywhereRoommates / TVThe public internet
SSHthe whole machineon the LAN onlyno
Jellyfinover HTTPSplain HTTP, in the houseno
Syncthing adminover HTTPSnono
Syncthing syncyesport open, but every peer must be approved by IDno
Downloader panelover HTTPSnono
n8n editorover HTTPSnono
ollamano — n8n only, inside Dockernono
These docsvia SSH tunnel to localhostnono

The right-hand column is entirely no, and that is the headline. The router forwards no ports at all. Remote access works because your devices and homie both reach outward and meet in the middle — never because anything was opened for the world to knock on.

Every network this machine is on

NetworkRangehomie isWho else is on it
House WiFi (LAN)192.168.1.0/24192.168.1.10 Everyone in the house, plus the router at .1. Jellyfin is the only admin-free thing listening here
Tailscale (tailnet)100.64.0.0/10100.64.0.10 Only approved devices — currently homie and the MacBook. Every admin page is here and nowhere else
Docker bridge
homeserver_default
172.18.0.0/16172.18.0.1 The containers, and nothing outside the machine. Their individual addresses are assigned in start order and are not stable — read them with docker network inspect, never from a document
The VPN tunnel10.5.0.0/2410.5.0.2 inside gluetun Just gluetun and the NordVPN server. qBittorrent sees this and nothing else
Docker’s default bridge172.17.0.0/16172.17.0.1 Nobody. It exists because Docker always makes one; no service here uses it
The public internet—via the router Everything outbound. The house has one public address; torrent traffic does not use it
Read as a rule

Which network something is on decides who can reach it. That is the whole security design — not passwords, and not a firewall.

One line each

Tailscalemakes the machine reachable, privately, from anywhere
Caddyterminates HTTPS and routes browser traffic to services
Dockerisolates each service and forwards chosen ports to it
Syncthingkeeps one folder identical across my own devices
Jellyfinserves media, converting formats when a device needs it
Gluetunholds the VPN tunnel and owns the namespace qBittorrent lives in
qBittorrentdownloads, then renames and files what it downloads
n8nruns the scheduled automations and owns their credentials
ollamaanswers n8n’s language-model calls locally, on CPU
SSHgives the shell and the raw files, bypassing all of the above