proximo

Architecture — how it works

← back to docs index

proximo combines three things developers usually wire up by hand — Host-based routing, local DNS, and a trusted certificate — into one tool. This page explains how the pieces fit together.

The big picture

            ┌─────────────────────────── your host ───────────────────────────┐
            │                                                                   │
  browser   │   /etc/resolver/<tld>            system + NSS trust stores        │
  https://  │   (macOS)  ─┐                    └─ contain the proximo local CA  │
  app.test  │             │  *.test → 127.0.0.1:5354/udp                        │
     │      │   resolved  ┘                                                     │
     │      │   drop-in (Linux)                                                 │
     │      │                                                                   │
     ▼      │                  ┌──────────── docker compose stack ───────────┐ │
  127.0.0.1 │   :5354/udp ─────┼──▶ dns      (miekg/dns: *.tld → 127.0.0.1)   │ │
  :443 ─────┼──▶ traefik ◀─────┼─── watcher  (labels → dynamic config + certs)│ │
            │      │           │       │  attaches traefik to backend nets    │ │
            │      ▼           └───────┼──────────────────────────────────────┘ │
            │   your container  ◀──────┘  http://<name>:<port>                   │
            │   (proximo.hosts=app.test)                                         │
            └───────────────────────────────────────────────────────────────────┘
  1. The browser resolves app.test through the host resolver, which sends *.<tld> queries to the local DNS server → 127.0.0.1.
  2. The browser opens https://app.test; Traefik (publishing :443) terminates TLS with a CA-signed certificate your system already trusts.
  3. Traefik matches the Host header to a router and forwards to your container over the Docker network the watcher attached it to.

The CLI is a one-shot orchestrator

There is no proximo daemon on your host. Each CLI command performs its action and exits:

The only long-running processes are the stack containers. They are an embedded docker compose project, materialized to ~/.proximo/stack/ from assets compiled into the binary (//go:embed), with the TLD and DNS port substituted in.

The stack: four services

The Go services share one published image, ghcr.io/filippolmt/proximo:<cli-version> — every in-stack binary in it, one picked per service with an entrypoint; the two peer services, on a machine that shares, run it too. Nothing is compiled on your host, and the tag is the CLI's own version, never latest, so a binary can never pull services speaking a label contract it has never seen (ADR 0002).

Service Image Role
traefik traefik:v3.7 Reverse proxy. Published on the loopback only (ADR 0014): terminates HTTPS on 127.0.0.1:443, listens on 127.0.0.1:80 (no redirect by default — a host opts in with proximo.redirect), routes by Host. Two providers: the Docker provider (native traefik.* labels) and the file provider watching /etc/traefik/dynamic. Its built-in dashboard (api.dashboard, read-only — api.insecure stays off) is served at https://traefik.<tld>.
dns proximo (dnsserver) Wildcard DNS server (miekg/dns). Answers *.<tld> → 127.0.0.1, forwards everything else upstream. Published on 127.0.0.1:5354/udp.
watcher proximo (watcher) Reads container labels, writes Traefik dynamic config + per-container certificates, and attaches Traefik and the inspector to backend networks. Records Incidents from the Docker event stream and publishes them on a loopback-only read API for proximo errors. Keeps the effective-route inventory in ~/.proximo/data/inventory. Mounts the Docker socket and the CA.
inspector proximo (inspector) The Inspection hop. In the request path only for containers labelled proximo.inspect; idle otherwise. Publishes a loopback-only read API for proximo errors and holds Exchanges in memory, never on disk.
peer-dns proximo (dnsserver) Only on a machine that shares: answers this machine's peer subtree on its mesh address, <address>:5354, and refuses every other name. Profile-gated and not a core service — see the peer DNS service.
peer-relay proximo (peerrelay) Only on a machine that shares: published on <address>:443, copies TCP to Traefik, which listens on the loopback only. Profile-gated and not a core service — see the peer relay.

The watcher and Traefik share the host directory ~/.proximo/data/traefik (bind-mounted into both at /etc/traefik/dynamic, not a Docker named volume): the watcher writes routes + certs to it, Traefik's file provider reads from it. Because it is a bind mount, the data is visible on the host and survives a docker volume prune. See the state home.

Every stack service shares one Compose network, proximo_default — the Stack network. Its IPv4 subnet is declared, never taken from Docker's default pool: 10.101.0.0/24 unless moved with proximo config subnet, passed to Compose as PROXIMO_SUBNET in the stack's .env. A converge that finds the network on another subnet takes the stack down and recreates it, and goes back to the old subnet if Docker refuses the new one (ADR 0009).

An opt-in observability profile (up --observability) adds two more services — Dozzle (logs) and a Beszel hub + agent (metrics) — routed through the same label contract at logs.<tld> / metrics.<tld> (both opt into the HTTP→HTTPS redirect). They are inert unless the flag is passed, and the hub persists into the ~/.proximo/data/beszel bind mount. See Dev-time observability.

DNS

The DNS server (internal/dns) is intentionally tiny:

It publishes on host port 5354/udp (not 53, to avoid a privileged bind; not 5353, which macOS mDNSResponder already owns). The host resolver points the TLD at it:

Because DNS answers *.<tld>, which hostnames exist is driven entirely by the labels you set — there is no per-host DNS registration.

TLS and trust

proximo runs its own certificate authority so HTTPS is trusted with no browser warning and no public ACME round-trip.

The watcher

The watcher (internal/docker/watcher.go) runs a reconcile loop — once at start, then on Docker events, with a 30s safety resync. Each reconcile:

  1. Finds Traefik (the container labeled proximo.role=traefik).
  2. Selects routed containers — opted in via proximo.hosts (and not proximo.enable=false), or via native traefik.enable=true. Stack containers (proximo.role) are never routed. Every rule about what a container's labels declare lives in internal/docker/labels.go, so the watcher, proximo status and the Incident store cannot disagree about whether a container is proximo's and what it asked for.
  3. Resolves the backend port — proximo.port if set; otherwise the single exposed TCP port (auto-detected via ContainerInspect); ambiguous cases are skipped with a warning.
  4. Attaches Traefik to backend networks so it can reach the container by name, and detaches from networks no longer needed.
  5. Writes Traefik dynamic config — one HTTP router + service file per proximo-labeled container, targeting http://<container-name>:<port>. Stale files are removed.
  6. Issues per-container certificates — reissuing only when a container's host set changes, and removing certs when a container stops being routed.

The backend URL uses the container name, not its IP: once Traefik is on the container's network, Docker's embedded DNS resolves the name, and names survive restarts whereas IPs change.

The same event stream feeds one more thing. Every message is offered to the Incident store before the reconcile runs: an exit, a restart, an OOM kill or a transition to unhealthy, for any container proximo knows — routed, or carrying proximo.transcript. The store is here rather than in the inspector because the watcher is the only stack service holding both the Docker socket and the subscription, and the inspector deliberately has neither: it sits in the request path. It is memory only, capped per service and by age, and published on a loopback port for proximo errors. See ADR 0007.

The dashboard self-route

The Traefik container itself carries proximo.role=traefik and is therefore excluded from container routing — so the watcher injects a self-route on every reconcile, independently of the container list: a router for Host(traefik.<tld>) targeting Traefik's internal api@internal service (no backend port), plus a CA-signed leaf with traefik.<tld> as a SAN, listed in proximo-tls.yml like any other. Unlike user containers (opt-in via proximo.redirect), the self-route always includes the HTTP→HTTPS redirect: http://traefik.<tld> 302-redirects to https:// instead of 404ing on :80. The route lives under the stable filename proximo-dashboard.yml (outside the proximo-route-* cleanup glob) and its reserved dashboard id is part of the active set each pass, so stale cleanup never collects either file. The watcher learns the TLD from the PROXIMO_TLD environment variable the compose file passes it (same as the dns service), falling back to the default TLD. traefik.<tld> is reserved for the stack.

See Routing for the label contract that drives all of this.

Peer sharing

How a route labelled proximo.share reaches a colleague's browser — the how-to is sharing.md, the label routing.md. Every part of it is inert on a machine that has not opted in.

The peer DNS service

Constraints 6, 9. ADR 0013.

proximo's DNS server answers this machine's own peer subtree, <machine>.<suffix>, and each colleague's resolver is routed to it by the mesh. Nothing central holds records about the machines: a machine's routes are known only to that machine, and are answered only by it, at any depth. A machine that is off stops answering for its names; nothing else answers for it with a stale address.

The peer relay

ADR 0014.

Traefik is published on 127.0.0.1 only, so a colleague's request cannot reach it directly. The peer relay is what listens on <address>:443: it accepts TCP and copies each connection, byte for byte, to traefik:443 over the Stack network.

The peer route

Constraints 1, 4. ADR 0012.

For a shared HTTP route the watcher writes a second router on the websecure entrypoint. Its rule is the alternation of the route's peer names (with the route's path prefix, when it has one); it points at the same service as the local router — the Inspection hop's, when the route is inspected — and carries the same middleware chain. Host is never rewritten. With proximo.redirect it gets the same redirect on web.

Peer certificates and the default certificate

Constraints 7, 8. ADR 0010.

Source map

Path Responsibility
main.go, internal/cli/ The proximo command surface (Cobra).
internal/config/ Persisted config (TLD, the peer-sharing values and their validation), per-user paths.
internal/dns/ The wildcard DNS server + host-resolver wiring.
internal/tls/ Local CA, leaf issuance, system + NSS trust; the team root as a second anchor, the machine key, its CSR and its intermediate (intermediate.go, peer.go).
internal/docker/ Embedded stack (assets/), compose driver, the watcher, what a container's labels declare (labels.go), the Incident store and its loopback read API.
internal/observability/ Opt-in observability: generated hub secret + env files, Beszel hub-client bootstrap.
internal/platform/ OS / package-manager detection, privileged host ops.
internal/checks/ The Check registry, the Report, and the host readings behind them (proximo doctor, and the pre-install subset install/up gate on).
internal/transcript/ Reading a Transcript back: the window an Exchange or an Incident fixes, the cut, and the silences it tells apart.
internal/inspect/ The Inspection hop: the injected agent (assets/agent.js), response injection, CSP reconciliation, report ingest, the in-memory Exchange store.
cmd/dnsserver/, cmd/watcher/, cmd/inspector/ Entrypoints for the in-stack services — all three built into the one published image by the root Dockerfile.
tools/team-ca/ The custodian's tool: mints the team root and signs intermediates, run through Docker by team-ca.sh. Not shipped in the CLI.