
Self-hosted family SFTP: exposing a NAS without port forwarding using Pangolin + Newt + SFTPGo
You want to expose a service (SFTP, RDP, game server, any other TCP/UDP) hosted on a residential network behind a consumer router, without port forwarding and without asking your users to install a VPN client. Required level: comfortable with Docker, WireGuard, Traefik and the shell.
The problem: an exposed SFTP you no longer want
Starting point: four non-technical users access folders on the NAS from a mainstream Android SFTP client. The UX constraint is hard — open the app, type host / port / user / password, done. No VPN to install.
The legacy setup was a plain sshd exposed on the Internet: port 42222 on the consumer router, TCP port forwarding to the NAS, fail2ban, one Linux user per person with a ChrootDirectory. Its flaws:
- Huge attack surface: every bot on the planet hammers non-standard SSH ports non-stop.
- The sshd serving these users is the same one used for admin. One kernel or OpenSSH bug and everything burns.
- No centralised audit of connections and transfers.
- The NAS needs a port open on the Internet, which means trusting the router firewall, the NAT, and the ISP's box.
Target when migrating the NAS from Synology DSM to Debian 13 (see the migration article): not a single port open on the NAS, SFTP served from a bastion VPS, tunnel outbound from the NAS.
Why not Cloudflare Tunnel, Tailscale Funnel or a plain reverse proxy
Cloudflare Tunnel — free and robust, but exposing raw TCP SFTP requires Service Tokens, meaning every user has to install cloudflared and configure a certificate. Dead on arrival for non-technical users. And all the data goes through Cloudflare.
Tailscale Funnel — simple UX, but Funnels only work on Tailscale.com, not on a self-hosted Headscale. And adding every user to the tailnet just to upload three photos is disproportionate.
Caddy / nginx as a TCP reverse proxy — doable with Caddy's layer4 module, but no outbound WireGuard orchestration: the WG tunnel has to be brought up manually, with peers and WG IPs to manage for every new service.
The pick: Pangolin, a self-hosted control plane that combines Traefik (HTTP + raw TCP/UDP), a WireGuard server (Gerbil), an outbound tunnel connector (Newt), and a management dashboard. Open-source (AGPL), REST API, Docker. Raw TCP/UDP is supported since 1.0.0-beta.9 through "raw resources". Exactly the Cloudflare Tunnel pattern, but entirely on your own boxes.
Target architecture
The client opens a TCP connection to 203.0.113.10:2022. Traefik listens on that entryPoint, Pangolin has configured a raw TCP route that pushes the flow into the WireGuard tunnel. Gerbil sends it through the tunnel set up by Newt on the NAS side, which routes it to sftpgo:2022 on the local Docker network.
What changes everything: the tunnel is brought up by Newt connecting outbound from the NAS. Zero ports open, no port-forward. All you need is outbound UDP to the VPS being allowed, which is the default on any residential connection.
VPS side setup: Pangolin, Traefik, Cloudflare DNS-01
The VPS runs Debian 13, deployment in /home/debian/pangolin/:
pangolin/
├── docker-compose.yml
└── config/
├── config.yml # Pangolin server config
├── cf_dns_api_token.txt # Cloudflare API token secret (mode 600)
├── traefik/
│ ├── traefik_config.yml # Traefik static config
│ └── dynamic_config.yml # Pangolin dynamic routes override
├── letsencrypt/
│ └── acme.json # persisted Let's Encrypt certs
└── db/db.sqlite # Pangolin SQLite DB
docker-compose.yml
name: pangolin
services:
pangolin:
image: fosrl/pangolin:1.18.4
container_name: pangolin
restart: unless-stopped
volumes:
- ./config:/app/config
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/api/v1/']
interval: 3s
timeout: 3s
retries: 15
gerbil:
image: fosrl/gerbil:1.4.0
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./config/:/var/config
cap_add:
- NET_ADMIN
- SYS_MODULE
ports:
- 51820:51820/udp # WireGuard for Newt connectors
- 21820:21820/udp # WireGuard hole-punch relay
- 443:443 # Traefik HTTPS (network_mode service:gerbil)
- 80:80 # Traefik HTTP (ACME challenge + redirect)
- 2022:2022 # Raw TCP - SFTPGo via Newt
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # shares gerbil's network stack
environment:
- CF_DNS_API_TOKEN_FILE=/run/secrets/cf_dns_api_token
secrets:
- cf_dns_api_token
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./config/traefik:/etc/traefik:ro
- ./config/letsencrypt:/letsencrypt
secrets:
cf_dns_api_token:
file: ./config/cf_dns_api_token.txt
Three things worth noting:
network_mode: service:gerbilon Traefik: it shares Gerbil's network stack, and Gerbil exposes the ports (80, 443, 2022).localhost:80from Traefik = port 80 on Gerbil.- The Cloudflare token is a Docker secret, mounted at
/run/secrets/cf_dns_api_token, source filechmod 600root on the host. - The
2022:2022port is added to Gerbil to expose a raw TCP resource. For raw UDP (a game server), addXXXX:XXXX/udp.
Traefik: entry points and certResolver
config/traefik/traefik_config.yml:
api:
insecure: false
dashboard: false
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint: { to: websecure, scheme: https, permanent: true }
websecure:
address: ':443'
http:
tls:
certResolver: letsencrypt
domains:
- main: 'example.org'
sans: ['*.example.org']
http3:
advertisedPort: 443
tcp-2022:
address: ':2022/tcp'
certificatesResolvers:
letsencrypt:
acme:
email: 'admin@example.org'
storage: /letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
dnsChallenge:
provider: cloudflare
resolvers: ['1.1.1.1:53', '1.0.0.1:53']
delayBeforeCheck: 30
providers:
http:
endpoint: 'http://pangolin:3001/api/v1/traefik-config'
pollInterval: '5s'
file:
filename: /etc/traefik/dynamic_config.yml
experimental:
plugins:
badger:
moduleName: 'github.com/fosrl/badger'
version: 'v1.4.0'
crowdsec-bouncer:
moduleName: 'github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin'
version: 'v1.4.4'
Pangolin pushes Traefik routes dynamically through its HTTP provider API. For it to route a raw resource on port 2022, the Traefik entryPoint must be named tcp-2022 (format protocol-port). Name it anything else and Pangolin won't find it. Same goes for UDP (udp-XXXX).
Pangolin: the allow_raw_resources flag
In config/config.yml:
flags:
require_email_verification: true
disable_signup_without_invite: true
disable_user_create_org: true
allow_raw_resources: true # ← critical
gerbil:
start_port: 51820
base_endpoint: 'pangolin.example.org'
app:
dashboard_url: 'https://pangolin.example.org'
By default this flag is false, and the "Raw TCP/UDP resource" button doesn't appear in the dashboard. It's a deliberate safety net: you explicitly declare that your instance is allowed to expose non-HTTP ports.
Cloudflare DNS
Three A records in grey cloud (not orange-proxy — incompatible with raw TCP without a paid Cloudflare Spectrum subscription), all pointing to the VPS IP:
| Name | Type | Value | Proxy |
|---|---|---|---|
pangolin.example.org | A | 203.0.113.10 | DNS only |
auth.example.org | A | 203.0.113.10 | DNS only |
files.example.org | A | 203.0.113.10 | DNS only |
NAS side setup: Newt + SFTPGo
On the NAS, in /mnt/data/apps/pangolin-stack/:
name: pangolin-stack
services:
newt:
image: fosrl/newt:1.12.5
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://pangolin.example.org
- NEWT_ID=${NEWT_ID}
- NEWT_SECRET=${NEWT_SECRET}
networks: [pangolin-stack]
cap_add: [NET_ADMIN]
mem_limit: 128m
sftpgo:
image: drakkan/sftpgo:v2.7-alpine
container_name: sftpgo
restart: unless-stopped
user: '1000:1000'
environment:
- TZ=Europe/Paris
- SFTPGO_HTTPD__BINDINGS__0__PORT=8080
- SFTPGO_HTTPD__BINDINGS__0__ADDRESS=0.0.0.0
- SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_ADMIN=true
- SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_CLIENT=true
- SFTPGO_SFTPD__BINDINGS__0__PORT=2022
- SFTPGO_SFTPD__BINDINGS__0__ADDRESS=0.0.0.0
ports:
- '100.64.0.10:8080:8080' # admin UI reachable only over the internal WireGuard
volumes:
- ./config/sftpgo:/var/lib/sftpgo
- /mnt/data/Family:/data/Family
- /mnt/data/Videos:/data/Videos
- /mnt/data/Alice:/data/Alice
- /mnt/data/Bob:/data/Bob
networks: [pangolin-stack]
mem_limit: 256m
networks:
pangolin-stack:
driver: bridge
The SFTPGo admin UI is bound to 100.64.0.10:8080 (the NAS IP on the internal admin WireGuard network, separate from the Pangolin tunnel), not 0.0.0.0. It's reachable neither from the LAN nor from the Internet.
Creating the Newt site in Pangolin
- Menu Sites → + Add site → type Newt, name
nas - Toggle "Accept client connections" on
- Click Create site, then copy the
IDandSecret(shown only once) - Paste them into
.envon the NAS side:
NEWT_ID=xxxxxxxxxxxxxxxx
NEWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- On the NAS:
docker compose up -d
Creating the raw TCP resource
Menu Resources → Public → + Add resource:
- Type: Raw TCP/UDP resource (only appears if
allow_raw_resources: true) - Name:
sftp-family, Protocol: TCP, Public port:2022 - Site:
nas, Target:sftpgo:2022(Docker hostname resolved through thepangolin-stacknetwork)
The WireGuard tunnel is already up, so routing kicks in instantly.
The provider firewall trap
First start: Newt can't bring up its tunnel, the logs keep looping on:
INFO Websocket connected
INFO Connecting to endpoint: pangolin.example.org
INFO SendMessageInterval timed out after 16 attempts for message type: newt/wg/get-config
WARN Ping attempt 1 failed: failed to read ICMP packet: i/o timeout
The websocket works, but newt/wg/get-config times out. On the Pangolin side, the handleNewtGetConfigMessage handler explains why:
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn(`Site last hole punch is too old; skipping this register.`)
return
}
In the SQLite DB, endpoint, publicKey and lastHolePunch are all empty: the UDP hole-punch never lands. A tcpdump on the VPS for udp port 21820 sees zero packets, while outbound UDP from the NAS to Cloudflare, NTP and Google STUN gets answers.
The decisive test is run from a different network (here WSL on another ISP):
import socket
for port in [21820, 9999, 12345]:
try:
s = socket.create_connection(("203.0.113.10", port), timeout=3)
print(f"TCP VPS:{port}: OK")
except Exception as e:
print(f"TCP VPS:{port}: {e}")
Every port times out, while 22, 80 and 443 answer. So the culprit isn't the ISP, it's the VPS provider's firewall. In the Infomaniak manager → VPS → Firewall, the existing rules only cover TCP 80, 443, 22, ICMP, UDP 3478, 41641. Everything else is deny-by-default on inbound.
Two rules to add (UDP 51820 for WireGuard Gerbil, UDP 21820 for the hole-punch relay), then restart Newt:
INFO Tunnel connection to server established successfully!
INFO Client connectivity setup. Ready to accept connections from clients!
The raw resource's TCP 2022 port needs the same opening before external SFTP clients can connect.
Before theorising about your ISP or exotic DPI, check the VPS provider's firewall. Every modern cloud host (Infomaniak, OVH Cloud, Scaleway Stardust, Hetzner Firewall, AWS Security Groups) applies a deny-by-default policy on inbound, even when the Linux system has no iptables rules at all. And always test from a different network (4G, another VPS) before blaming your own.
Security hardening
Nothing stops an attacker from hammering 203.0.113.10:2022 all day. CrowdSec protects Traefik at the HTTP layer, but not raw TCP: the connection lands in passthrough mode, with no L7 inspection.
The SFTPGo defender: a false lead
SFTPGo ships with a "Defender" module that temporarily bans IPs after N failed attempts. On paper:
environment:
- SFTPGO_COMMON__DEFENDER__ENABLED=true
- SFTPGO_COMMON__DEFENDER__DRIVER=memory
- SFTPGO_COMMON__DEFENDER__BAN_TIME=30 # 30 minutes
- SFTPGO_COMMON__DEFENDER__THRESHOLD=15
- SFTPGO_COMMON__DEFENDER__SCORE_INVALID=2
- SFTPGO_COMMON__DEFENDER__SCORE_NO_AUTH=2
In practice, behind the Newt tunnel, every external connection appears to SFTPGo with the same source IP: 172.23.0.1, the Docker bridge gateway on the NAS. The WireGuard tunnel NAT erased the real client IP. The first bad password from anyone bumps the 172.23.0.1 counter, and at threshold everyone is locked out — including the admin:
{"sender":"SSH","message":"connection refused, ip \"172.23.0.1\" is banned"}
PROXY protocol: the proper fix, which doesn't work yet
The reverse proxy prefixes the TCP connection with a header containing the real source IP, which the backend uses as the apparent source. Pangolin supports it since PR #1739 ("Enable Proxy Protocol" checkbox, v1 recommended), and SFTPGo too:
- SFTPGO_COMMON__PROXY_PROTOCOL=1 # 1 = optional, 2 = required
- SFTPGO_COMMON__PROXY_ALLOWED=172.23.0.1 # IP allowed to send the header
But with Pangolin 1.18 / Newt 1.12 / SFTPGo 2.7, the forward through Newt breaks silently: the TCP connection opens on Traefik:2022, zero bytes reach SFTPGo, no SSH banner is returned, no explicit error at INFO level. To be dug into with Traefik DEBUG + tcpdump, and probably reported upstream.
The trade-off taken
Defender disabled (SFTPGO_COMMON__DEFENDER__ENABLED=false), compensated by:
- 16-to-20 character passwords generated by a password manager (~104 bits of entropy), not brute-forceable at SSH handshake rate
- monitoring SFTPGo logs (failed attempts are still logged with defender off)
- SFTPGo audit log tracking every action (downloads, uploads, mutations)
- fail2ban on the VPS for port 2022 as a future hardening item (parsing Traefik TCP access logs)
Acceptable with four known users and long passwords. The SFTPGo defender is designed for direct exposure, not for a reverse-proxy-tunnel setup where the source IP disappears in NAT.
SFTPGO_COMMON__DEFENDER_CONFIG__* doesn't work, even though the debug log shows DefenderConfig:{Enabled:false ...} — that's the internal Go struct field name, not the config key. The right key is common.defender, so SFTPGO_COMMON__DEFENDER__ENABLED.
Same story for PROXY protocol: it isn't a per-binding setting (SFTPGO_SFTPD__BINDINGS__0__PROXY_PROTOCOL) despite the apply_proxy_config flag existing at the binding level, but a global setting under common, shared across all bindings. The distinction only becomes clear in the config dump at startup.
Virtual folders: per-user granular ACLs
The feature that makes SFTPGo worth picking. Each user gets a chrooted home_dir (/srv/sftpgo/users/<username>), and you mount host directories at virtual paths with distinct permissions per folder. Everything is declared via the REST API, instead of hand-rolling an sshd with ChrootDirectory + manual bind mounts:
# Creating a user with read/write on Family + Videos
import urllib.request, json, base64
auth = base64.b64encode(b"homeadmin:xxx").decode()
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/token",
headers={"Authorization": f"Basic {auth}"})
TOKEN = json.loads(urllib.request.urlopen(req).read())["access_token"]
FULL = ["list","download","upload","overwrite","delete","rename",
"create_dirs","create_symlinks","chmod","chtimes"]
# 1) Declare the global virtual folders
folders = [
{"name": "family", "mapped_path": "/data/Family"},
{"name": "videos", "mapped_path": "/data/Videos"},
]
for f in folders:
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/folders",
data=json.dumps(f).encode(),
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
# 2) Create the user with their virtual folders
payload = {
"username": "alice", "password": "xxx", "status": 1,
"home_dir": "/srv/sftpgo/users/alice",
"permissions": {"/": ["list"], "/Family": FULL, "/Videos": FULL},
"virtual_folders": [
{"name": "family", "virtual_path": "/Family"},
{"name": "videos", "virtual_path": "/Videos"},
],
}
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/users",
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
Once logged in, the user only sees their virtual folders at the root:
sftp> ls -la
drwxr-xr-x 1 0 0 0 Jan 1 1970 Family
drwxr-xr-x 1 0 0 0 Jan 1 1970 Videos
The permissions: {"/": ["list"]} only allows ls at the root, no file creation. Inside each folder, full permissions apply.
SFTPGo admin 2FA (TOTP)
The admin web UI handles user management, folders and the audit log. Enabling: top-right avatar → Two-factor authentication → Default → scan the TOTP QR code → validate with a 6-digit code.
Save the recovery codes in a password manager: without them, losing your phone means recreating an admin via the SFTPGo CLI, bypassing auth. On the TOTP app side (Aegis, 2FAS, Ente Auth), also enable an encrypted backup.
Rotating the Cloudflare DNS-01 token
- Cloudflare → API Tokens → Create Token → "Edit zone DNS" template → Zone
example.org→ TTL 1 year - On the VPS, edit
cf_dns_api_token.txtmanually withsudo nano(notecho $TOKEN, which leaves the secret in bash history) chmod 600owned by root, thendocker compose restart traefik- Verify the token is valid:
sudo sh -c 'TOKEN=$(cat /home/debian/pangolin/config/cf_dns_api_token.txt); \
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer $TOKEN"'
# → {"success":true,"result":{"id":"...","status":"active"}}
- Verify the container sees the new token:
sudo sh -c 'echo Host: $(sha256sum /home/debian/pangolin/config/cf_dns_api_token.txt | cut -c1-16)
echo Container: $(docker exec traefik sha256sum /run/secrets/cf_dns_api_token | cut -c1-16)'
- Once a cert renewal is validated, revoke the old token in Cloudflare.
A secret must never transit through a chat with an AI assistant, nor into local session logs, nor into its persistent memory. The assistant guides the commands, the human runs them. Same goes for SSH private keys and anything that ends up in a .env.
Cost and comparison
| Solution | Cost/month | Data goes through | User-side UX | Self-hosted |
|---|---|---|---|---|
| Internet-exposed sshd + fail2ban | 0 € | (nothing) | Standard SFTP | ✅ |
| Cloudflare Tunnel (HTTP only) | 0 € | Cloudflare | Decent web app | ❌ |
| Cloudflare Tunnel + Spectrum (TCP) | 5 €+ | Cloudflare | OK but Service Tokens | ❌ |
| Tailscale Funnel | 0 € | Tailscale Cloud | VPN setup required | ❌ |
| Pangolin + Newt + SFTPGo | ~3 € (VPS Lite) | Personal VPS | Standard SFTP | ✅ |
| Headscale + custom reverse proxy | ~3 € | Personal VPS | Variable | ✅ |
The monthly delta vs a directly exposed SFTP is just the bastion VPS (Infomaniak VPS Lite at €3.12 ex-VAT, which also hosts Pangolin, Headscale and a few other services). In exchange: zero ports open on the consumer router, NAS isolated, a management dashboard, centralised audit log, admin 2FA, automatic wildcard certificates, and the ability to add more exposed services (RDP, Minecraft) without touching the home network.
The reflex to keep when a network packet vanishes into thin air: test from another network, list the VPS provider's firewall rules, tcpdump both ends simultaneously, read the service's logs before theorising at the network layer. The rest — Pangolin, Newt, SFTPGo, the outbound WireGuard architecture — comes up in under five minutes once the provider firewall is out of the way.
Related articles