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.
┌─────────────────────────── 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) │
└───────────────────────────────────────────────────────────────────┘
app.test through the host resolver, which sends
*.<tld> queries to the local DNS server → 127.0.0.1.https://app.test; Traefik (publishing :443) terminates
TLS with a CA-signed certificate your system already trusts.Host header to a router and forwards to your container
over the Docker network the watcher attached it to.There is no proximo daemon on your host. Each CLI command performs its action and exits:
install → generate CA, configure resolver, install trust, compose up.up / down → start / stop the stack.update → converge the running stack to the installed CLI version (pull the
stack image pinned to it, re-pull Traefik); a soft no-op when Docker/stack is
down.config tld → rewrite the resolver and restart the stack.uninstall → reverse all host changes, compose down.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 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.
The DNS server (internal/dns) is intentionally tiny:
app.test, api.test) →
authoritative A record 127.0.0.1. Non-A types return NOERROR with no
records (the TLD is IPv4 loopback only).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:
/etc/resolver/<tld> with nameserver 127.0.0.1 and port 5354.systemd-resolved drop-in
(/etc/systemd/resolved.conf.d/proximo-<tld>.conf) with
DNS=127.0.0.1:5354 and Domains=~<tld>, then systemd-resolved is
restarted.Because DNS answers *.<tld>, which hostnames exist is driven entirely by the
labels you set — there is no per-host DNS registration.
proximo runs its own certificate authority so HTTPS is trusted with no browser
warning and no public ACME round-trip.
internal/tls) — a P-256 ECDSA CA generated on first
install (10-year validity, IsCA, path-len 0) and reused afterwards. Stored
as tls/ca.pem + tls/ca-key.pem in your state home
(~/.proximo).certutil) when
present. uninstall removes both.*.<tld> is deliberately avoided: browsers reject
TLD-level wildcards like *.test. Exact SANs are used instead.The watcher (internal/docker/watcher.go) runs a reconcile loop — once at
start, then on Docker events, with a 30s safety resync. Each reconcile:
proximo.role=traefik).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.proximo.port if set; otherwise the single
exposed TCP port (auto-detected via ContainerInspect); ambiguous cases are
skipped with a warning.http://<container-name>:<port>. Stale
files are removed.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 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.
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.
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.
A for any name under <machine>.<suffix>, at any
depth, with the configured <address>; AAAA and every other type with
NOERROR and no records, as the loopback handler does for the TLD. Every name
outside the subtree gets REFUSED: no upstream forwarding and no .test,
so the publishing machine is never a recursive resolver for the mesh.<address>:5354, UDP and TCP.
A publish bound to an address no interface holds leaves its container
created, and Docker's restart policy never retries it. Inside the dns
service that would take .test down on every machine whose mesh is down at
start; in a service of its own it fails alone. Every interface is not an
option: Docker refuses a second publish of the loopback port, and it would
expose the listener on the LAN.machine, the Peer suffix and address are all
configured. In any other state the service is absent from the materialised
Compose file, which is then byte-identical to that of a machine that does not
share; configured, the service is appended after the others and is
profile-gated, so the core up never waits on it. The certificates are not
required: a name that resolves while TLS fails is a stated state, not an error.created or
exited, the watcher runs docker start on it on its fixed 30-second tick, never on an
event, and logs a failed start once. It never
inspects host interfaces, touches no other service, and does nothing on a
machine where the service does not exist.stack requires, so a
mesh problem never fails stack and never skips the rest of doctor.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.
created without touching Traefik, which is why <address>:443
is not one of Traefik's own publishes.:443. The mesh admits TCP 443 and 5354 between peers.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.json), the intermediate and the machine key — into the stack's
ca/ directory, which the watcher already mounts, so the Compose file does not
change; every config setter copies it at once and the watcher re-reads it each
reconcile (internal/docker/peer.go). Without them the route
stays local and fully working. A peer router served under an untrusted chain
would warn exactly as an interception does.:443 on every
interface already, and the mesh client runs on the host beside the stack.
Docker Desktop's forwarder serves the mesh interface. proximo calls no mesh API,
discovers nothing and names no vendor: it is told a label, a suffix and an
address.Constraints 7, 8. ADR 0010.
proximo-team-root.crt), its own NSS nickname (proximo team root), and on
macOS deletion by SHA-1 fingerprint, because delete-certificate -c matches a
substring of a common name the team chose..test leaves: one per shared container, its peer names as exact
SANs, 397 days, reissued when its peer names change and removed when the
container stops being shared. The certificate file presents the intermediate
after the leaf, since a colleague's machine holds only the root. Traefik reads
them through the same file provider. SNI selects between a route's two leaves..test name with no route stays in the error
class it had before: a trusted issuer and the wrong name. sniStrict is not
used: it is global, and would turn local clients without SNI into handshake
failures.| 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. |