Swarm ignores docker-compose.yaml's build: key, so docker stack deploy previously needed an operator to build and tag the control-plane image by hand. CI now publishes it to this instance's container registry on every push to master (or a tag), and docker-compose.yaml's image: key (overridable via GATEWAY_CONTROL_PLANE_IMAGE) lets Swarm pull it directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142mP6EqCkQKmR1xCp7zfmZ |
||
|---|---|---|
| .forgejo/workflows | ||
| control_plane | ||
| ops | ||
| tests | ||
| traefik | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yaml | ||
| pyproject.toml | ||
| README.md | ||
furtka-gateway
WireGuard (wg-easy) + Traefik reverse-proxy that lets a Furtka box — typically sitting behind NAT/CGNAT with no public IP — expose individual apps to the internet under a real domain with automatic TLS.
A Furtka box becomes a WireGuard peer of this gateway. The gateway's Traefik
reverse-proxies public HTTPS traffic over that tunnel to whichever apps the
box owner has explicitly published. Only apps whose Furtka manifest.json
declares internet.viable: true can be published at all, and publishing is
always an explicit per-app opt-in on the box side — the gateway never exposes
anything on its own.
Supports two deployment modes from the same codebase:
single— the common case: one person/operator runs this on their own VPS with their own domain's A/AAAA records pointed at it, for their own Furtka box(es).shared— one larger, multi-tenant instance (operated by the Furtka project) for people who don't want to run their own. Same schema, same code;singleis just the one-account case.
Status: Phase 2 — the control plane (accounts/boxes/routes, wg-easy peer provisioning, Traefik dynamic-config generation) is built and tested for single-tenant mode. The Furtka-core side (the box-facing "connect to a gateway" flow and per-app "expose to internet" toggle) is still just a plan, not yet implemented — see the plan doc for the phased delivery.
Deploying on a VPS
- Get a VPS with a static public IP, point a domain's A/AAAA record at it
(e.g.
example.com), and open80/tcp,443/tcp,51820/udpin whatever firewall/security group sits in front of it. cp .env.example .envand fill it in — in particularGATEWAY_PUBLIC_HOST(the WireGuard endpoint hostname), a wg-easy admin password (WG_EASY_PASSWORD_HASH+ the matching plaintextWG_EASY_ADMIN_PASSWORD),GATEWAY_BOX_TOKEN(openssl rand -hex 32), andGATEWAY_CONTROL_PLANE_HOST(its own A/AAAA record — see step 5). Double every$inWG_EASY_PASSWORD_HASHas$$— see that variable's comment in.env.examplefor why; getting this wrong silently truncates the hash instead of erroring, confirmed by hitting it directly.- Run
sudo ops/host-sysctls.shon the VPS before bringing the stack up. wg-easy needsnet.ipv4.ip_forward=1(so the host actually routes between the docker bridge and the WireGuard interface — see thenetwork_mode: hostcomment on thewg-easyservice) andnet.ipv4.conf.all.src_valid_mark, and — confirmed by actually trying it, not assumed — Docker's ownsysctls:key can't set either one once a service is host-networked; runc refuses to even start the container. - Bring the stack up:
docker compose up -d(ordocker stack deployunder Swarm — see the caveats below,build:doesn't work there yet). - Copy
traefik/dynamic/control-plane.yml.exampletotraefik/dynamic/control-plane.yml, substituting your realGATEWAY_CONTROL_PLANE_HOST. Without this, the registration API is only reachable at127.0.0.1:8090on the VPS itself — a remote Furtka box has no way to reach it, and the call needs TLS anyway since it carries a WireGuard private key (see that file's comments, andcontrol_plane/wgeasy.py's module docstring, for why). - Run
sudo ops/firewall.shon the VPS. wg-easy's admin API is no longer kept off the public interface by Docker the way every other service here is (a direct consequence ofnetwork_mode: host— see step 3). This script closes that with an iptables rule. Do this before pointing DNS at the box for real — read the script's own caveats first, it hasn't been validated against a real multi-interface VPS yet. curl http://127.0.0.1:8090/healthzfrom the VPS to confirm the stack is actually up.
Docker Swarm caveats
If deploying via docker stack deploy instead of docker compose up:
docker-compose.yaml'sbuild: ./control_planedoesn't work under Swarm at all — it only ever schedules pre-built, tagged images. Swarm falls back to theimage:key on the same service instead, which.forgejo/workflows/build-image.ymlkeeps published to this instance's container registry on every push tomaster(taggedlatestplus the short commit sha) — no manual build/tag step needed. SetGATEWAY_CONTROL_PLANE_IMAGEin.envfirst if you want a specific commit-sha tag rather than whateverlatestcurrently points to.docker stack deploydoes not read a.envfile next to the compose file the waydocker compose updoes —set -a; source .env; set +afirst, or the${VAR}substitutions come out empty.depends_onis silently ignored by Swarm — harmless here, sincecontrol_planeonly talks to wg-easy/Traefik lazily on the first real request, not at startup.
Local dev
cp .env.example .env
# GATEWAY_PUBLIC_HOST can be a LAN-reachable IP for a first smoke test.
docker compose up -d
curl http://127.0.0.1:8090/healthz
ops/firewall.sh and the control-plane.yml Traefik route are about
reaching this gateway from the real internet — skip both for local dev.