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 three Go services share one published image,
ghcr.io/filippolmt/proximo:<cli-version> — all three binaries in it, one
picked per service with an entrypoint. 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. Terminates HTTPS on :443, listens on :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. 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. |
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.
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.
| Path | Responsibility |
|---|---|
main.go, internal/cli/ |
The proximo command surface (Cobra). |
internal/config/ |
Persisted config (TLD), per-user paths. |
internal/dns/ |
The wildcard DNS server + host-resolver wiring. |
internal/tls/ |
Local CA, leaf issuance, system + NSS trust. |
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. |