Root README now lets you choose between the two paths, with requirements, the three ways to route Traefik to WireGuard peers (including the bridge-network recipe verified on a live Traefik v3.7 + wg-easy v15), box connection, troubleshooting and security notes. The starter-stack README lists server, firewall and DNS requirements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.8 KiB
furtka-gateway
Lets a Furtka box — typically at home behind NAT or CGNAT, with no public IP — publish individual apps on the internet under a real domain, with automatic HTTPS.
internet ──443──▶ Traefik ──WireGuard tunnel──▶ Furtka box (behind NAT) ──▶ app
▲
Furtka box ──51820/udp──▶ wg-easy (box dials out; no port forwarding at home)
A box connects as a WireGuard peer of wg-easy. Traefik then reverse-proxies
HTTPS traffic through that tunnel to the apps the box owner has explicitly
published. Only apps whose Furtka manifest declares internet.viable: true can
be published at all, and each one is a separate opt-in on the box — the
gateway never exposes anything by itself.
This repo's own code is the control-plane (control_plane/, one small
stdlib-only Docker image). It creates WireGuard peers in wg-easy when a box
registers, and writes Traefik file-provider routes when a box publishes an app.
Choose your setup
| Option A: New server | Option B: Existing Traefik + wg-easy | |
|---|---|---|
| For | An empty VPS | A server that already runs Traefik and wg-easy |
| Runs | wg-easy + Traefik + control-plane | control-plane only |
| Setup | examples/starter-stack/deploy.sh, 4 prompts |
ops/deploy.sh, plus one routing step you do yourself |
Option A: New server
Full guide, including server and DNS requirements: examples/starter-stack/README.md.
In short: a Linux VPS with Docker, ports 80/tcp, 443/tcp, 51820/udp
open, and a wildcard DNS record *.example.com pointing at it. Then:
git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git
cd furtka-gateway/examples/starter-stack && ./deploy.sh
Option B: Existing Traefik + wg-easy
Requirements
- Traefik v3 with a file provider directory (
providers.file.directory). The control-plane writes one file per published app into it. If your Traefik usesproviders.file.filenametoday, switch it to a directory and move your existing file into it — Traefik allows only one file provider. - wg-easy 14 or 15. Their admin APIs differ; you tell the deploy script which one you run. On v15, the control-plane sets wg-easy's default "Allowed IPs" for new clients to your peer subnet (split tunnel) on every start. Existing clients aren't touched, but new non-Furtka clients (phones, laptops) also get split tunnel unless you edit them.
- Docker Compose v2.24+ on the server.
- A route from Traefik to wg-easy's peer subnet (default
10.8.0.0/24) — see the next section. This is the one step you do yourself. - DNS: an A record for the gateway API hostname (e.g.
gw.example.com) pointing at your Traefik, plus one per published app — or simply a wildcard*.example.com. Any registrar works; certificates come from your Traefik's existing resolver. - No new ports. Traefik's 443 and wg-easy's 51820/udp are already open.
Routing: Traefik must reach the WireGuard peers
Published routes point Traefik at a box's tunnel address (10.8.0.x). That
address only exists inside wg-easy's network namespace, so a standard setup
(wg-easy on a Docker bridge network) can't reach it. Pick whichever fits:
1. wg-easy on a bridge network (most common) — route the subnet through wg-easy and NAT it. Verified on a live Traefik v3.7 + wg-easy v15 setup.
a. Pin wg-easy's address on Traefik's network (in wg-easy's compose file), so the route below stays valid:
networks:
traefik:
ipv4_address: 172.19.0.5 # any free address in that network's subnet
b. Route the peer subnet from the host to wg-easy, permanently
(/etc/systemd/system/furtka-gateway-route.service):
[Unit]
Description=Route WireGuard peer subnet via wg-easy (furtka-gateway)
Requires=docker.service
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'for i in $(seq 90); do ip route replace 10.8.0.0/24 via 172.19.0.5 && exit 0; sleep 2; done; exit 1'
ExecStop=/sbin/ip route del 10.8.0.0/24 via 172.19.0.5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now furtka-gateway-route
c. Make replies come back through the tunnel: in wg-easy's admin UI (Admin → Hooks), append to PostUp
iptables -t nat -A POSTROUTING -s 172.19.0.0/16 -o wg0 -j MASQUERADE;
and to PostDown the same line with -D instead of -A (use your
Traefik network's subnet). Then restart wg-easy once.
2. Traefik in wg-easy's network namespace — no routes or NAT at all; this is what Option A does. Only practical if you can restructure both containers.
3. wg-easy with network_mode: host — wg0 then lives on the host. Needs
net.ipv4.ip_forward=1 on the host, and a firewall rule keeping wg-easy's
admin port (51821) off the public interface, because host networking publishes
it.
Check it, once a box is connected:
docker exec <your-traefik> ping -c1 10.8.0.<box>
Deploy
git clone https://forgejo.sourcegate.online/robert/furtka-gateway.git
cd furtka-gateway
ops/deploy.sh --render-only # optional: inspect .env and rendered/control-plane.yml first
ops/deploy.sh
It asks for your wg-easy URL, version and login, your Traefik's dynamic
directory, cert resolver and docker network, and the API hostname. It then
builds and starts the control-plane on your Traefik's network, installs its
route into the dynamic directory, and checks https://<api-host>/healthz.
Finding the answers:
| Prompt | Where to look |
|---|---|
| wg-easy URL | http://<wg-easy container name>:51821 if it's on your Traefik's network |
| Version | The tag of your wg-easy image (docker ps) |
| Dynamic directory | The host path mounted at your Traefik's providers.file.directory |
| Cert resolver | certificatesResolvers.<name> in your Traefik's static config |
| Docker network | docker inspect <traefik> --format '{{json .NetworkSettings.Networks}}' |
Update later with git pull && ops/deploy.sh --non-interactive. The image is
always rebuilt from your checkout.
Connect a box
Same for both options. On the Furtka box: Settings → Gateway, enter the
gateway URL (https://gw.example.com), the registration token
(GATEWAY_BOX_TOKEN in the server's .env) and any box name. Then on the Apps
page, open an app's ⋯ menu → Expose to Internet, and give it a hostname
covered by your DNS.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Box: Unit wg-quick@furtka-gw.service does not exist |
Box image lacks wireguard-tools. Install it (pacman -S wireguard-tools) or reinstall from a newer ISO, then click Connect again — the existing registration is reused. |
| Box says connected, but wg-easy shows no handshake | Older box builds don't write wg-easy v15's preshared key into the tunnel config. Update the box. |
| App URL gives 502 / timeout, but the box's tunnel is up | Traefik can't reach 10.8.0.x — see Routing. |
| No "Expose to Internet" in an app's menu | That app's manifest lacks internet.viable: true. |
deploy.sh: control-plane not healthy |
docker compose logs control-plane — usually a wrong wg-easy URL or password. |
https://<api-host>/healthz fails after deploy |
DNS not pointing at Traefik yet, certificate still being issued, or Traefik isn't watching the dynamic directory. |
Security notes
- A box's registration response contains its WireGuard private key, so the API
is only exposed through Traefik over HTTPS. The direct port is bound to
127.0.0.1:8090. GATEWAY_BOX_TOKENlets anyone register a box. Treat it like a password; rotate it by deleting it from.envand re-running the deploy script.- Keep wg-easy's admin UI (port 51821) off the public internet.
Docker Swarm (Option B only)
build:doesn't work under Swarm. It uses theimage:CI publishes on every push tomaster(latestplus the short commit sha); setGATEWAY_CONTROL_PLANE_IMAGEin.envto pin one.docker stack deploydoesn't read.env—set -a; source .env; set +afirst.- Attach the service to your Traefik's network in the stack file's own
networks:section (the generateddocker-compose.override.yamlisn't used). - Option A's stack can't run under Swarm (
network_mode: service:).
Development
python -m pytest # control-plane tests
ruff check . && ruff format --check .
GATEWAY_MODE is a tenancy switch: single (one operator — everything above)
or shared (multi-tenant; same code, not covered here).