
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. Bonus: you have four non-technical relatives who need access to folders on the NAS from their phone. Required level: comfortable with Docker, WireGuard, Traefik and the shell.
The context: four relatives, a simple app, zero UX tolerance
Four people - let's call them Alice, Bob, Claire and David - have been sending and pulling photos, videos and documents from my NAS for years. They all use CX File Explorer on Android, a free file manager app that natively speaks SFTP, FTP, WebDAV and SMB. None of them knows what a VPN is. Bob asks me for his password every six months. Claire mixes up "tap Save" and "tap the play triangle". The setup has to be: open the app, type a host, a port, a user, a password, and it works.
For a long time I had a plain SFTP exposed on the Internet. An sshd on port 42222 of the consumer router, with TCP port forwarding to the NAS, fail2ban behind it, one Linux user per person with a ChrootDirectory. It worked. But:
- The attack surface is huge: every bot on the planet hammers non-standard SSH ports non-stop
- The sshd serving these family users is the same one I use for admin. One kernel or OpenSSH bug and everything burns
- No clean 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
When I migrated my NAS from Synology DSM to Debian 13 (see the migration article), I wanted to rethink everything. Target: the NAS must no longer have a single port open on the Internet. SFTP should be served from a cheap VPS acting as bastion, and the tunnel must be outbound from the NAS.
Why not Cloudflare Tunnel, Tailscale Funnel or a plain reverse proxy
Before settling on Pangolin, I seriously evaluated the alternatives:
Cloudflare Tunnel (formerly Argo Tunnel) - the most well-known option. Free, robust, outbound tunnel via the cloudflared binary. The catch: to expose raw TCP SFTP (not HTTP), you need their Service Tokens, which means every user has to install cloudflared and configure a certificate. Dead on arrival for non-tech relatives. And more importantly: all the data goes through Cloudflare. For family photos, I'd rather keep that between Alice and my NAS.
Tailscale Funnel - expose a port of a Tailscale node to the public Internet through Tailscale servers. Simple UX for the family (one public host, one port). But: the outbound tunnel goes through Tailscale.com (external control plane), and Funnels only work on Tailscale.com, not on the Headscale instance I self-host. My tailnet also contains internal services I don't want exposed to the Tailscale accounts of my relatives. Adding Alice to my tailnet so she can upload three photos is wildly disproportionate.
Caddy / nginx as a TCP reverse proxy - doable with Caddy's layer4 module, but Caddy doesn't orchestrate the outbound WireGuard side from the NAS. I'd have to bring the WG tunnel up manually, manage peers, reallocate WG IPs every time I add a site. Fine for a one-off, tedious when you want a dashboard to add and remove services.
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. Since version 1.0.0-beta.9 it supports raw TCP/UDP through "raw resources". Exactly the Cloudflare Tunnel pattern, but entirely on my own boxes.
Target architecture
The flow on Alice's side: her CX File Explorer 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. Newt receives the traffic and routes it to sftpgo:2022 on the local Docker network of the pangolin-stack compose. SFTPGo authenticates Alice and serves her virtual folders.
What changes everything compared to a classic setup: the tunnel is brought up by Newt connecting outbound from the NAS. The NAS has zero ports open on the Internet. The consumer router has no port-forward configured. All you need at home is outbound UDP to the VPS being allowed, which is the default on any non-cellular residential connection.
VPS side setup: Pangolin, Traefik, Cloudflare DNS-01
The VPS runs Debian 13. All deployment files live in /home/debian/pangolin/. The structure:
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
The minimal compose (pangolin, gerbil, traefik, crowdsec):
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 - family 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 here:
network_mode: service:gerbilon Traefik. Traefik shares Gerbil's network stack, so it doesn't expose its own ports - Gerbil exposes them (80, 443, 2022). It also simplifies routing between the two:localhost:80from Traefik = port 80 on Gerbil.The Cloudflare token is a Docker secret, mounted at
/run/secrets/cf_dns_api_token. The source file ischmod 600root on the host. Traefik reads the content via theCF_DNS_API_TOKEN_FILEvariable.The
2022:2022port is added to Gerbil when you want to expose a raw TCP resource on that port. To expose raw UDP (a game server for example), 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 in the config it pushes. 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 allow_raw_resources is false. As long as it stays that way, the "Raw TCP/UDP resource" button doesn't appear in the resource creation form in the dashboard. I spent ten minutes hunting for it the first time, wondering if I had missed a version. 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) 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 |
For raw TCP SFTP, the orange Cloudflare proxy wouldn't work without a paid Cloudflare Spectrum subscription. DNS only it is.
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 my internal admin WireGuard network, separate from the Pangolin tunnel), not on 0.0.0.0. It's only reachable from my admin machines, never from the LAN, and obviously never from the Internet. SFTPGo's web UI is full-featured and powerful - I really don't want it exposed.
Creating the Newt site in Pangolin
In the Pangolin dashboard:
- Menu Sites → + Add site → type Newt, name
nas - Toggle "Accept client connections" on
- Click Create site
- 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
Still in the dashboard, menu Resources → Public → + Add resource:
- Type: Raw TCP/UDP resource (this option only appears if
allow_raw_resources: truein the Pangolin config) - Name:
sftp-family - Protocol: TCP
- Public port:
2022 - Site:
nas - Target:
sftpgo:2022(Docker hostname of the SFTPGo container, resolved through thepangolin-stacknetwork)
Click Create resource. The WireGuard tunnel is already up, so routing kicks in instantly.
The two-hour detour: Free vs Infomaniak
Setup done, I'm pleased with myself. First docker compose up -d on the NAS. 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 to Pangolin works, but newt/wg/get-config times out. I dive into the Pangolin code (/app/dist/server.mjs) and find the handleNewtGetConfigMessage handler:
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn(`Site last hole punch is too old; skipping this register.`)
return
}
I check the SQLite DB on the Pangolin side:
SELECT siteId, endpoint, publicKey, lastHolePunch FROM sites WHERE siteId=2;
2 | (empty) | (empty) | (empty)
The endpoint, public key and lastHolePunch are all empty. So Newt never manages to UDP hole-punch to Gerbil. I run a tcpdump on the VPS for udp port 21820: zero packets in 30 seconds. On the NAS, the Newt container does log Failed to start hole punch: hole punch already running (so it's trying).
I test outbound UDP from the NAS to various destinations:
import socket
for ip, port in [("1.1.1.1", 53), ("213.251.128.249", 123),
("stun.l.google.com", 19302), ("203.0.113.10", 21820)]:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(b"X"*32, (ip, port))
And tcpdump on the VPS in parallel: packets to Cloudflare, OVH NTP and Google STUN come back, but packets to my VPS never show up in the tcpdump. Meanwhile, NAS Tailscale traffic (UDP port 41641) reaches the VPS, so it's not a blanket block.
My working theory at this point, which I was delighted with: Free (my French ISP) is doing signature-based DPI on outbound UDP to this destination, dropping anything that doesn't look like already-known Tailscale traffic. The Freebox stateful firewall would only allow flows previously established via STUN or UPnP. Beautiful theory. Obviously wrong.
I burn two hours:
- Connecting to the Freebox API v15 (with its lovely authorisation protocol:
app_token+ physical button on the box + HMAC-SHA1 login) - Disabling the Freebox adblock (suspecting a disguised DPI proxy) - no effect
- Enabling Freebox DMZ pointing to the NAS to bypass the router firewall - no effect
- Manual UPnP mapping for inbound port 21820 - no effect
- Reading ProxyGuard and the WireGuard docs on symmetric NAT
- Searching for "Free DPI WireGuard outbound 2026" without finding anything convincing
Then, on a hunch, I test from WSL (a completely different network from mine):
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}")
Result:
TCP VPS:21820: timed out
TCP VPS:9999: timed out
TCP VPS:12345: timed out
But TCP 22, 80 and 443 to the VPS work fine from WSL. So the problem isn't Free, isn't the consumer router, isn't exotic DPI. It's the VPS provider's firewall only letting through ports that are explicitly open. In short, I blamed my ISP for two hours over a checkbox in a web admin panel.
I go to the Infomaniak manager → my VPS → Firewall → existing rules: TCP 80, TCP 443, TCP 22, ICMP, UDP 3478, UDP 41641 (the historical Tailscale ports). Everything else is deny-by-default on inbound.
I add two rules:
UDP 51820sourceAnydescriptionWireGuard GerbilUDP 21820sourceAnydescriptionGerbil hole-punch relay
Restart Newt on the NAS. Result:
INFO Tunnel connection to server established successfully!
INFO Client connectivity setup. Ready to accept connections from clients!
The tunnel comes up in three seconds. I check the Pangolin DB:
SELECT siteId, endpoint, publicKey, lastHolePunch FROM sites WHERE siteId=2;
2 | 198.51.100.20:51234 | aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdEFG= | 1779740844
Before theorising about your ISP, your residential network 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, WSL on a different ISP) before blaming your own.
Later on when I added the TCP 2022 port (the SFTP raw resource), I had to redo the firewall opening in the Infomaniak manager before external SFTP clients could connect. Testing from a network other than the one I'm developing on has become a reflex.
Security hardening
At this stage the architecture stands up fine, but nothing stops an attacker from hammering 203.0.113.10:2022 all day brute-forcing my relatives' passwords. CrowdSec is installed on the VPS and protects Traefik fine at the HTTP layer, but it doesn't cover raw TCP: the connection lands directly on Traefik in passthrough mode, with no L7 inspection.
Anti-bruteforce: the SFTPGo Defender false lead
SFTPGo ships with a "Defender" module that temporarily bans IPs after N failed attempts. Exactly what's missing here. On paper, enabled via environment variables in the NAS compose:
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
Fifteen invalid attempts in a thirty-second window (or cumulative "score" of 30+) → IP banned for thirty minutes. Repeat offender, sixty. Etc.
Except 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. Direct consequence: the first bad password from anyone bumps the 172.23.0.1 counter, and at threshold everyone is locked out — including the admin. You see it in the logs:
{"sender":"SSH","message":"connection refused, ip \"172.23.0.1\" is banned"}
The proper architectural fix: PROXY protocol — which doesn't work (yet)
The right fix exists: PROXY protocol (v1 or v2). The reverse proxy (here Traefik on the VPS) prefixes the TCP connection with a header containing the real source IP; the backend (SFTPGo) reads that header and uses it as the apparent source. The defender becomes useful again.
Pangolin supports this natively since PR #1739: an "Enable Proxy Protocol" checkbox on the raw TCP resource, with a version selector (v1 recommended, v2 supported). 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
Except in my tests (Pangolin 1.18, Newt 1.12, SFTPGo 2.7), the forward through Newt breaks silently: the TCP connection opens on Traefik:2022, but zero bytes reach SFTPGo. No SSH banner returned to the client. The PROXY header seems lost somewhere between Traefik → WG tunnel → Newt → backend. No explicit error in Traefik/Newt/SFTPGo logs at INFO level. To be dug deeper (Traefik DEBUG + tcpdump on the NAS side) and probably reported upstream at fosrl/pangolin.
Pragmatic compromise: defender off, strong passwords
Until the PROXY proto chain is reliable, I've disabled the defender:
- SFTPGO_COMMON__DEFENDER__ENABLED=false
Compensated by:
- 16-to-20 character passwords generated by the 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)
In this context (4 known users, long passwords, no public anonymous service), it's an acceptable trade-off. The SFTPGo defender is designed for direct exposure (consumer router port forward, or public server), not for a reverse-proxy-tunnel setup where the source IP disappears in NAT.
My first defender attempt used SFTPGO_COMMON__DEFENDER_CONFIG__* as the env var name, because the SFTPGo debug log shows DefenderConfig:{Enabled:false ...}. That's the internal Go struct field name, not the config key. The right JSON/YAML key is common.defender (no _config), so the env var is SFTPGO_COMMON__DEFENDER__ENABLED.
Same story for PROXY protocol: a quick read suggests proxy_protocol is a per-binding setting (SFTPGO_SFTPD__BINDINGS__0__PROXY_PROTOCOL), since there's an apply_proxy_config flag at the binding level. But it's actually a global setting under common, shared across all bindings. The distinction only becomes clear when reading the config dump at startup: Common:{... ProxyProtocol:1 ProxyAllowed:[...]} SFTPD:{Bindings:[{... ApplyProxyConfig:true}]}. Always validate against official docs AND the actual config dump, never against intuition.
Virtual folders: per-user granular ACLs
The feature that made me pick SFTPGo is its virtual folders. Each user gets a chrooted home_dir (/srv/sftpgo/users/<username>), and you mount host directories at virtual paths with distinct permissions per folder. Instead of hand-rolling an sshd with ChrootDirectory + manual bind mounts (I've been there, it's painful), everything is declared via the REST API:
# Creating Alice 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"},
{"name": "alice", "mapped_path": "/data/Alice"},
]
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 user Alice with her 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, Alice sees at the root:
sftp> pwd
Remote working directory: /
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
No Bob folder, no NAS root, nothing else. The permissions: {"/": ["list"]} only allows ls at the root (she can see her virtual folders exist), no file creation at the root. Inside each folder, full permissions apply.
SFTPGo admin 2FA (TOTP)
The SFTPGo admin web UI handles user management, folder management, the audit log, and everything else sensitive. Enabling 2FA:
- Admin UI → top-right avatar → Two-factor authentication
- Pick Default under Configuration → TOTP QR code shown
- Scan with a TOTP app. In 2026, I recommend Aegis on Android (open-source, local encrypted vault, biometrics, exports). On iOS, Raivo OTP. For cross-platform open-source, 2FAS or Ente Auth.
- Validate with a 6-digit code
- Save the recovery codes in a password manager. Without that, losing your phone means recreating an admin via the SFTPGo CLI, bypassing auth.
Second trap on Aegis: enable an encrypted backup (menu → Settings → Backups → Active → pick a folder on personal cloud). Too many people lose their TOTP codes simply because they never exported Aegis before their phone died.
Rotating the Cloudflare DNS-01 token
For the ACME DNS-01 challenge, Traefik uses a Cloudflare API token scoped to my zone. That token is a persistent secret that should be rotated regularly. The procedure I follow:
- On Cloudflare → API Tokens → Create Token → "Edit zone DNS" template → Zone
example.org→ TTL 1 year - On the VPS over SSH, edit the
cf_dns_api_token.txtfile manually withsudo nano(not viaecho $TOKEN, which leaves the secret in bash history) chmod 600owned by rootdocker compose restart traefik- Verify the token is valid via the Cloudflare API:
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 Traefik container actually sees the new token (Docker secrets in compose mode are bind mounts, restart suffices, but you can compare sha256):
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 validated in prod (a cert renews successfully, or I force a re-issuance by deleting a cert from
acme.json), revoke the old token in Cloudflare.
No secrets in an AI assistant conversation
During this session I almost pasted the new Cloudflare token into the chat with my assistant. Reflex to build: a secret must never transit through an LLM conversation, nor into local session logs, nor into the assistant's persistent memory. Self-service procedure only - the assistant guides the commands, the human runs them. Same goes for self-hosted password managers, SSH private keys, and roughly anything that ends up in a .env.
Family side: the message that requires no skill
Once the stack is up, the message I send Alice looks something like this:
Hey Alice! The file share has moved. Here's how to reconnect:
1. Install "CX File Explorer" from the Play Store (free, ~5 MB)
2. Open it, go to the "Network" tab (at the bottom)
3. "+" button top right → pick "SFTP"
4. Fill in:
- Host (server): files.example.org
- Port: 2022
- Username: alice
- Password: (the password I'm sending you separately on Signal)
5. Tick "Remember password", tap OK
You'll see your Family and Videos folders just like before.
No VPN to install, works from anywhere (home, 4G, hotel).
If anything stops working, ping me.
No extra client to install, no certificate to approve, no third party. Alice can be at her daughter's house, on holiday, on a flaky hotel router: as long as she has Internet, it works.
CX File Explorer remembers the profile, so from the second time onwards it's: open the app, tap "Family", online.
Cost and comparison
| Solution | Cost/month | Data goes through | Family-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 in my case, 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 Let's Encrypt wildcard certificates, and the ability to add more exposed services (RDP, Minecraft, anything) without touching the home network.
The lesson, again
What I keep from this session isn't the technical stack. It's two hours wasted blaming my ISP when the culprit was sitting one panel over, three clicks and a new form away. My reflex for the next time a network packet vanishes into thin air:
- Test from another network (4G, another VPS, a friend's). Kills the ISP theory in thirty seconds.
- Explicitly list the VPS provider's firewall rules (Infomaniak, OVH, Scaleway, Hetzner, AWS Security Group). It's often the cause, and it's invisible from
iptableson the machine itself. - Tcpdump both ends simultaneously. Shows where the packet dies.
- Read the service's logs before testing hypotheses at the network layer.
The rest - Pangolin, Newt, SFTPGo, the outbound WireGuard architecture - is just assembling solid bricks. Once the provider firewall is out of the way, everything comes up in under five minutes.
The NAS sleeps quietly behind its router. Alice sends her photos. And I finally close that "replace the exposed SFTP" ticket that had been sitting around for way too long.
Related articles