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 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.

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 portproximo.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.

Source map

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.