Skip to main content

Operator Network Access

How to reach the QTG backend (port 8100) and dashboard (port 3000) securely from an operator workstation when Docker Compose binds only to 127.0.0.1.

Why localhost binding by default

infra/docker-compose.yml binds all service ports to 127.0.0.1 (loopback only):
This is a default-deny posture: a port bound to 0.0.0.0 is reachable from any host that has network access to the Docker host, including other VMs on the same subnet, cloud metadata services, and misconfigured firewall rules. The assumed operator threat model is that the Docker host is a single dedicated server that operators reach via SSH or Tailscale. Binding to 127.0.0.1 means no external traffic can reach QTG unless an explicit tunnel or rebind is in place — reducing the blast radius of a firewall misconfiguration from “full exposure” to “nothing exposed”. Dashboard write authentication is now per-operator HMAC v3 (nonce + timestamp), not a shared bearer token. The browser holds a non-extractable CryptoKey, so there is no static secret baked into the bundle to extract, and replayed requests are rejected. Localhost-only binding is therefore defense-in-depth — a network-layer backstop — rather than the primary access control. See Auth Migration: v1 to HMAC for the per-operator key model and migration steps.
Forward both ports over an existing SSH session to the Docker host:
With this tunnel open on your workstation:
  • Dashboard: http://localhost:3000
  • Backend API: http://localhost:8100
The -N flag keeps the tunnel open without opening a shell. Run it in a terminal or use autossh for a persistent tunnel. For long-lived production setups, add to ~/.ssh/config:
Then: ssh -N qtg-tunnel

Option 2: Tailscale rebind

If the Docker host is enrolled in a Tailscale tailnet, you can rebind the ports to the Tailscale IP so all tailnet members can reach the services directly. Rebind in docker-compose.yml (override the default 127.0.0.1):
Or use a docker-compose.override.yml on the server to keep the canonical compose file unchanged:
Alternatively, run the dashboard behind tailscale serve (refer to Tailscale’s own documentation for tailscale serve setup — do not replicate it here).

Residual risk with Tailscale

  • ACL discipline: every device on the tailnet can reach the rebind ports unless Tailscale ACLs are tightened to tag-based rules. Audit tailnet-policy.json before rebinding.
  • Operator offboarding: dashboard write access is now per-operator HMAC, so when a tailnet member is removed or an operator leaves, revoke that operator’s HMAC key rather than rebuilding a shared-token bundle. See Auth Migration: v1 to HMAC for the key lifecycle.

This topology keeps all QTG services off the public internet while giving authorised operators full dashboard and API access.

Further reading

Dashboard write authentication migrated from a shared bearer token to per-operator HMAC v3. For the key model, the MG_DASHBOARD_WRITE_TOKEN removal/startup guard, and the migration procedure, see:
Auth Migration: v1 to HMAC
Startup guard: main.py hard-fails at boot if MG_DASHBOARD_WRITE_TOKEN is present in the environment — even when set to an empty value (MG_DASHBOARD_WRITE_TOKEN=). Remove the key entirely from your environment / .env / docker-compose.yml before starting the server. VITE_DASHBOARD_WRITE_TOKEN is likewise removed from the frontend bundle.