Where the Gateway Runs
Every other component of Theta Suite is a container in one docker-compose.yml.
The gateway is the exception: it is meant to run on the host — bare metal, a
VM, or an LXC system container.
This page explains why, because “one of these is not like the others” is the
kind of decision that gets quietly undone later.
The short version
The gateway is a router. Routers need to be where the network interfaces
are. Every awkward thing about running one inside a Docker network namespace is
the router trying to escape that namespace.
What breaks in a container
A containerised gateway can still mesh with other sites, serve device VPNs, and
route traffic between sites. Those all live inside WireGuard and work fine in a
namespace.
What does not work is everything that touches the physical network:
- NETMAP has nothing to map. The shadow ranges exist to make each site’s
192.168.1.0/24 globally distinct. In a container there is no
192.168.1.0/24 — only a Docker bridge — so there is nothing on the other
side of the mapping.
MASQUERADE -o eth0 hits the wrong network. Inside a container, eth0 is
a veth on the Docker bridge, not the site uplink.
- Machines on your LAN cannot reach the mesh. The recommended static route
(
10.0.0.0/8 → the gateway) needs the gateway to be a host on your LAN. A
container is not.
- Port forwarding (planned) is DNAT on the host. Doing it into a namespace
is a second layer of translation for no benefit.
Why not the usual container escapes
Three standard workarounds were considered and rejected:
network_mode: host breaks five things at once. Docker DNS disappears, so
the gateway can no longer resolve openbao and the directory can no longer
resolve jump-host. ports: is silently ignored, so JUMP_SSH_PORT and
JUMP_WEB_PORT stop remapping anything. And the gateway’s bundled Redis — which
holds sessions, OAuth state and API tokens — binds 0.0.0.0:6379 on the machine
instead of staying in its namespace. That last one is a security regression, not
an inconvenience.
macvlan actually works and keeps Docker DNS (attach the container to both
the bridge and the macvlan network). But it needs a real Ethernet NIC in
promiscuous mode: not most WiFi adapters, and not most VPS providers. It also
brings the standard gotcha that the Docker host cannot reach its own macvlan
containers without a shim interface — awkward when that host is where you SSH
from.
Passing a physical NIC into the container works too (`ip link set eth1 netns
`), but Compose cannot express it, so it becomes a post-start script. The
namespace changes every time the container is recreated, so the next
`docker compose up -d` after an image update silently leaves the gateway without
its interface — a failure that appears exactly when you are least likely to be
looking.
Each of those is a different set of caveats and a different failure mode to
document. One host install has none of them.
## LXC, VM, or bare metal
From the gateway's point of view these are the same thing: its own init, its own
routing table, namespaced sysctls (`ip_forward` and `rp_filter` are per-netns),
working `iptables`/NETMAP, and real interfaces. One native install runs on all
three.
**LXC is the nice middle ground** if you want the gateway isolated from the
directory host. LXD attaches a physical or macvlan NIC *declaratively*:
```
lxc config device add gateway lan nic nictype=physical parent=enp3s0
```
which persists across restarts — the thing the Docker equivalent cannot do.
In-kernel WireGuard works inside LXC (WireGuard is namespace-aware; the host
loads the module, the container creates interfaces), so you get the kernel
datapath rather than the `wireguard-go` fallback.
## Installing it
`theta-suite`'s `setup.sh` does this for you. To do it by hand, or to upgrade:
```
sudo ./jump-host/install.sh
```
It installs `redis-server`, `iproute2`, `iptables`, `wireguard-tools` and
`procps` if missing, lays the app down in `/opt/theta-gateway`, writes
`/etc/theta-gateway/gateway.env`, binds Redis to loopback, and enables the
`theta-gateway` systemd service. Re-running upgrades in place; config and data
are left alone.
| Path | What |
|---|---|
| `/opt/theta-gateway` | the application (replaced wholesale on upgrade) |
| `/etc/theta-gateway/gateway.env` | settings — written once, never overwritten |
| `/etc/theta-gateway/jump-secrets.js` | LDAP bind, API token, OIDC (from `setup.sh`) |
| `/var/lib/theta-gateway` | SSH host keys and Redis data |
```
systemctl status theta-gateway
journalctl -u theta-gateway -f
sudo ./jump-host/install.sh --uninstall
```
`--uninstall` deliberately **keeps** `/var/lib/theta-gateway`, because that is
where this gateway's WireGuard identity lives and every peer in the cluster
holds its public half. Deleting it silently breaks every tunnel to this site.
It runs as **root**. It creates WireGuard interfaces, writes the routing table,
sets `net.*` sysctls in the host namespace and installs NAT and NETMAP rules —
all of which need `CAP_NET_ADMIN` in the init namespace, where sysctl writes
are effectively root-only. A capability-scoped user would buy ambiguity rather
than safety.
### Two things to get right
**The SSH port.** The gateway's front door defaults to 2222 so it does not
collide with the host's own `sshd` on 22. The installer refuses to proceed if
the port is already in use, rather than "succeeding" and leaving you unable to
reach the box.
**Reaching the rest of the stack.** The directory and OpenBao run as containers
on the same host and publish their ports, so the gateway talks to them over
loopback — `127.0.0.1:3001` and `127.0.0.1:8080` — rather than by service name.
In the other direction, containers reach the gateway at
`host.docker.internal:3002`, which is why `sso-manager` and `proxy` carry an
`extra_hosts: host.docker.internal:host-gateway` entry.
## What running it in a container actually cost
Worth recording, because the failure was quiet. The gateway image shipped
without `iptables` or `procps`, and `/proc/sys` is read-only in a default
container. So a containerised gateway could hold tunnels and route between
sites, but **every NAT, forwarding and NETMAP call failed** — and because the
forwarding call was not guarded, it threw mid-reconcile and the exit
configuration after it never ran at all. Exits were planned and silently never
applied.
Both are fixed (the router degrades and reports a limitation instead of
throwing), but the shape of the bug is the argument: a router in a namespace
fails in ways that look like a healthy mesh.