My home music server uses slskd, a Soulseek client, to get digital copies of records I own. Soulseek is peer to peer, and in any peer-to-peer network every peer you connect to sees your IP address. I don’t want strangers to know my home address, so I run it inside a VPN container.
That setup was private, but it had a cost. This post is about removing that cost without giving up the privacy, and about the two times I changed course on the way.
The setup, and its limit
The VPN client is gluetun with ProtonVPN over WireGuard. slskd doesn’t get a network of its own; it shares gluetun’s:
slskd:
network_mode: "service:gluetun" # all traffic through the VPN
gluetun’s firewall only lets traffic out through the tunnel, so if the VPN drops, slskd has no internet at all. There is no way for it to fall back to my home address.
The limit is reachability. In Soulseek, both downloads and search results are direct connections between two users, and at least one side must accept incoming connections. Behind a VPN with no open port, my side couldn’t. Anyone whose port was also closed could neither send me files nor answer my searches.
Opening a port on my router was never an option: that would put my home address back in front of every peer, which is the exposure the VPN removes.
Options and the decision
- Leave it. Private, simple, but fewer search results and fewer sources.
- Forward a port on the router. Rejected: it defeats the VPN.
- Use the VPN provider’s port forwarding. Proton opens one port on its own server and forwards it down the tunnel. My router stays closed, and peers only ever see Proton’s address.
Option 3 keeps the privacy and fixes reachability. The catch: Proton gives you a random port, and it can change. So something has to tell slskd which port to listen on, every time.
First version: a helper container
gluetun can request the port (over NAT-PMP) and run a command once it has one. I had it write the port to a shared file, and added a tiny helper container, the curl image running a shell loop. Every minute it read the file and told slskd’s API to listen on that port, if it wasn’t already.
It worked, apart from one thing: the API call returned 403. slskd only accepts runtime option changes when its remote configuration setting is on. And that setting does more than allow this one call. It also lets anyone logged in to slskd’s web page read and edit its whole config file, including the Soulseek password.
The page was only reachable from my home network, behind a password, so the risk was small. But I asked whether there was a better way, rather than accepting a security setting I didn’t like.
Second version: slskd’s built-in integration
There was. Researching slskd further, I found that version 0.24.4 added a VPN integration with native gluetun support. slskd polls gluetun’s control server for the VPN status and the forwarded port, and applies the port itself, internally. Remote configuration can stay off.
SLSKD_VPN: "true"
SLSKD_VPN_PORT_FORWARDING: "true"
SLSKD_VPN_GLUETUN_URL: http://127.0.0.1:8000
SLSKD_VPN_GLUETUN_API_KEY: ${GLUETUN_CONTROL_KEY}
The control server listens only inside gluetun’s network (the port isn’t published), and it requires an API key that is generated once on the server and stored only there. The helper container, its admin key and remote configuration all went away.
It also added a second safety lock: slskd now refuses to connect to Soulseek at all until gluetun reports the VPN as connected. Before, only the firewall stopped it.

The real bug: the server list
The new version had a trade-off I had accepted on purpose: if Proton gives no port, slskd waits instead of connecting without one. That made a flaky port a real problem, and it was flaky. Across the first four starts, a port was forwarded once. The other attempts failed against Proton’s NAT-PMP gateway, with either a timeout or “connection refused”.
My first guess was that the new WireGuard key needed time to propagate. That explained one success, but not the pattern. Two things gave the real answer:
- My Proton config had been made for a specific P2P server in Spain. gluetun ignored that and picked a random server in the Netherlands each time, from its own built-in list.
- That built-in list, in the release I was running, was about a year old. Some of the servers it marked as supporting port forwarding most likely no longer did.
The fix was to stop choosing from the list and connect to exactly the server the config was made for. gluetun’s “custom” provider takes the server’s public key and endpoint directly (neither is secret), and can still use Proton’s port-forwarding code:
VPN_SERVICE_PROVIDER: custom
WIREGUARD_PUBLIC_KEY: <the server's public key from the config>
WIREGUARD_ENDPOINT_IP: <the server's endpoint from the config>
WIREGUARD_ENDPOINT_PORT: "51820"
WIREGUARD_ADDRESSES: <the Address from the config>
VPN_PORT_FORWARDING: "on"
VPN_PORT_FORWARDING_PROVIDER: protonvpn
Then I tested it by restarting it several times. Three starts in a row, the port was forwarded within a second each time, and it was the same port every time.

Gotchas
- A new key isn’t ready straight away. The first starts with a freshly made Proton key failed; a later one worked. Give it a few minutes before debugging.
- The wrong suspect. Some downloads kept failing with “Completed, Cancelled” even with the port open. That turned out to be the download manager giving up on sources that queued or stalled, not the network. Retrying in slskd itself worked. The port fixed reachability, not that.
- Undo kept simple. Each version can be switched off by turning port forwarding off in two places, so trying it never risked the working setup.
Results
| Before | After | |
|---|---|---|
| Reachable by peers with closed ports | No | Yes |
| Home IP visible to peers | No | No |
| Router ports open | 0 | 0 |
| slskd remote configuration | Off | Off (on only in the first version) |
| Safety locks against running without the VPN | 1 (firewall) | 2 (firewall, slskd waits for the VPN) |
| Port forwarded on start | 1 out of 4 (random servers) | 3 out of 3 (pinned server), within a second |
What I gave up
Two things, both deliberate:
- One pinned server. If that Proton server is down for maintenance, the VPN stays down until I switch to another, which means making a config for a different P2P server and changing two lines. I prefer that to a port that works one time in four.
- Strangers can now start connections to slskd, through Proton’s port. Before, it only connected out. It’s one program in its own container, with the music library mounted read-only, and it already reads data from strangers on every download. I judged the extra exposure small and worth the reachability.
The yearly key renewal also has one more rule now: the new Proton config needs NAT-PMP turned on.
Skills used
Docker networking (shared network namespaces), WireGuard, gluetun, ProtonVPN NAT-PMP port forwarding, slskd configuration and API, researching open-source integrations, threat modelling and security trade-offs, log-driven troubleshooting and root-cause analysis, Bash and shell scripting, change management with easy rollback.