proximo

Routing — exposing a container

← back to docs index

Routing is opt-in: a container is exposed only when you label it. The primary path is the small proximo-native label set; native Traefik labels keep working for advanced cases.

The proximo labels

Label Required Default Meaning
proximo.hosts yes — Comma-separated hostname(s) under the configured TLD. Its presence opts the container in.
proximo.port no auto-detected Backend port. Omit when the image EXPOSEs exactly one port.
proximo.enable no true Opt-out switch. Set to false/0/no to park the container.
proximo.redirect no false Opt in to an HTTP→HTTPS redirect for the container's hosts. Truthy: true/1/yes.
proximo.path no — Path prefix (must start with /) scoping the routes, so several containers can share one host on distinct prefixes. Invalid values skip the container.
proximo.path.strip no false Strip the matched prefix before the backend (so /api/users arrives as /users). Truthy: true/1/yes.
proximo.health no true Gate routing on the container's Docker healthcheck: a container that declares one is routed only while healthy. Set to false/0/no to route as soon as it is running. No effect on containers without a healthcheck.
proximo.auth no — Require HTTP basic auth. Comma-separated user:password pairs; plaintext passwords are hashed on disk. A pair missing : is skipped with a warning.
proximo.cors no — Add CORS response headers. true for permissive CORS, or a comma-separated allowed-origin list. A blank value is skipped with a warning.
proximo.header.<Name> no — Add a custom response header <Name>: <value>. Repeatable; an invalid header name is skipped with a warning.
proximo.inspect no false Serve the container's HTTP routes through the Inspection hop, which injects a reporting agent into HTML responses and records what the browser reports. Truthy: true/1/yes. HTTP-only; ignored on TCP routes and on replica sets. See Inspection.
proximo.share no false Also serve the container's HTTP routes on their peer names, for colleagues on the team's mesh. Truthy: true/1/yes. Declares intent, not reachability. HTTP-only; ignored with a warning on TCP routes.
proximo.tcp.port no — Route the container's hosts over TCP-over-TLS by SNI on the given backend port (for DBs, gRPC, MQTT, HTTPS backends). Invalid values are skipped with a warning.
proximo.tcp.ports no — Comma-separated form of proximo.tcp.port. Note: SNI routes by host only, so several ports on one host cannot be told apart — give each TCP service its own host.
proximo.tcp.tls no terminate TLS mode for TCP routes: terminate (proxy terminates with the per-host proximo cert, forwards plaintext) or passthrough (proxy routes the raw TLS stream by SNI; the backend terminates).
proximo.transcript no false Routes nothing. Makes a container with no host known to proximo, so what the runtime declares about it — an exit, a restart, an OOM kill — is recorded and its output can be quoted. For workers, queue consumers and jobs. Truthy: true/1/yes. See Incidents.

Every label above is about how a container is reached, with one exception: proximo.transcript says a container can be quoted. Those are two independent axes — a worker has no host and still has output worth reading — and the label is listed here, among proximo's labels, because a label kept outside the list of labels is one nobody finds. It publishes no route, no certificate and no DNS name; on a container that is already routed it is redundant and accepted in silence.

Minimal example — the port is auto-detected because traefik/whoami exposes a single port:

services:
  whoami:
    image: traefik/whoami
    labels:
      - "proximo.hosts=whoami.test"
docker compose up -d
open https://whoami.test     # trusted HTTPS, no warning
proximo status

The two hosts every route gets

Every proximo route answers on two hosts, always, with no label to write and no switch to turn off:

Example Contested?
Bare host — what you declared api.test yes: one container serves it
Qualified host — with the Namespace inserted api.shop.test never

The Namespace is the container's Compose project name (shop above, _ rewritten to -), so the qualified host needs nothing from you beyond already using Compose. It is derived from the declared host, not the container name, so every replica of a scaled service shares it.

$ proximo status
CONTAINER   URL
shop-api-1  https://api.test  + api.shop.test

Both hosts go into the same router rule and the same certificate, so both are trusted HTTPS from the first request. Because the qualified host can never be taken from a container by another claimant, it is the name to put in a README, a .env, or a colleague's bookmark.

Two cases get no qualified host:

The stack's own routes (traefik.<tld>, and logs/metrics under observability) stay unqualified: the stack is not a project.

A collision inside one project is the one case the qualified host cannot soften — two containers of shop claiming api.test also claim api.shop.test, so the loser is left with nothing and proximo status says so. Give one of the two a different proximo.hosts.

Cookie scope. Under api.shop.test an app served at shop.test can set Domain=shop.test and be sent that cookie by every qualified host of the project — whereas api.test and db.test are isolated origins, because browsers refuse Domain=test for a single-label TLD. Inside one project that is usually what you want; it is a deliberate trade (ADR 0003).

proximo.hosts — opt in and pick the host(s)

Declaring proximo.hosts is the only mandatory step to be routed; no separate enable label is needed.

labels:
  - "proximo.hosts=app.test, api.test"   # both route to this container

Reserved host. traefik.<tld> (e.g. traefik.test) is reserved for the stack's own Traefik dashboard and must not be claimed via proximo.hosts — the dashboard route is injected by the watcher on every reconcile, so a container claiming it collides with the stack's router.

Collisions are reported, not resolved. When two containers claim one bare host, one of them serves it and the other is listed in proximo status with the reason and the name of the container that won — it keeps every other host it declared and stays reachable at its qualified host. See a host collision is reported.

proximo.port — usually you can omit it

The backend port is resolved as:

  1. proximo.port if set, else
  2. the single exposed TCP port, auto-detected by inspecting the container, else
  3. skipped — when the container exposes zero or several ports and no proximo.port is given, the container is not routed and a warning describing the ambiguity is logged (see where to read watcher warnings). proximo status reflects this: it shows the container flagged (⚠ set proximo.port) rather than as a working route, since the watcher does not actually serve it.
labels:
  - "proximo.hosts=app.test"
  - "proximo.port=8080"      # needed only when EXPOSE is 0 or many ports

proximo.enable — temporary opt-out

Defaults to true. Park a container without deleting its labels:

labels:
  - "proximo.hosts=app.test"
  - "proximo.enable=false"   # not routed until you flip it back

Falsy values are false, 0, no (case-insensitive). Anything else (or absence) means enabled.

proximo.redirect — opt in to the HTTP→HTTPS redirect

Defaults to false. By default a routed container is served on HTTPS only: https://<host> routes, while a plain http://<host> request is not redirected and not served — Traefik returns 404 on :80 for that host (the proxy still listens on :80, it just has no router for the host). Opt in per container to redirect HTTP to HTTPS:

labels:
  - "proximo.hosts=app.test"
  - "proximo.redirect=true"   # http://app.test -> https://app.test (302)

Truthy values are true, 1, yes (case-insensitive); anything else (or absence) leaves the redirect off. When enabled, the watcher writes an extra web-entrypoint router with a redirectScheme middleware for the container's hosts; the redirect is a 302 (non-permanent) so removing the label later is not sticky in browser caches. The HTTPS router is unchanged.

BREAKING (behavior change). Earlier versions redirected every host from HTTP to HTTPS globally. That global redirect is gone — the redirect is now opt-in per container. A host that relied on the automatic redirect must add proximo.redirect=true to keep it; the one-line fix is the label above. This takes effect on the next proximo update/up.

proximo.health — wait for the container to be healthy

Defaults to true. When a container declares a Docker HEALTHCHECK, proximo publishes its route and certificate only while it reports healthy, and withdraws the route when it turns unhealthy. This closes the window where a container is up but still booting (DB migrations, JIT warmup, slow start) and would otherwise answer 502/503 until it is actually ready.

services:
  app:
    image: my/app
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 5s
      retries: 5
    labels:
      - "proximo.hosts=app.test"   # routed only once health == healthy
labels:
  - "proximo.hosts=app.test"
  - "proximo.health=false"   # route on running, ignore the healthcheck

A broken healthcheck never reaches healthy, so the route never appears. proximo status shows the container as starting (not silently missing); proximo.health=false is the escape hatch.

proximo.path — split one host across containers

By default a host maps wholesale to one container. proximo.path=<prefix> scopes a container's routes to a URL prefix, so several containers can share one host under different prefixes — the classic SPA-plus-API split:

services:
  frontend:
    image: my/spa
    labels:
      - "proximo.hosts=app.test"          # serves everything else: app.test/
  backend:
    image: my/api
    labels:
      - "proximo.hosts=app.test"
      - "proximo.path=/api"               # app.test/api/... routes here
      - "proximo.path.strip=true"         # backend sees /users, not /api/users

proximo status lists each container with its prefix in the URL (https://app.test/api), so you can see the split at a glance.

proximo middlewares — auth, CORS, custom headers

Three curated labels reproduce the edge behavior you usually need in front of a dev container without hand-writing traefik.* middleware blocks. They are opt-in and compose: a router can carry all three, applied in the fixed order auth → CORS → custom headers. Each validates independently — a malformed value invalidates only that middleware (skipped with a warning), leaving the container's routing and its other middlewares intact.

services:
  api:
    image: my/api
    labels:
      - "proximo.hosts=api.test"
      - "proximo.auth=alice:s3cret, bob:hunter2"  # basic auth, two users
      - "proximo.cors=true"                        # permissive CORS
      - "proximo.header.X-Env=dev"                 # X-Env: dev on responses

proximo.auth — comma-separated user:password pairs. Requests without valid credentials get 401; valid ones are forwarded. Plaintext passwords are bcrypt-hashed when written to the proxy config, so the dynamic file never stores a cleartext secret. A value already in htpasswd hash form ($2y$/$2a$/ $2b$/$apr1$/$1$ prefix) is passed through unchanged — use it to keep the plaintext out of your compose file entirely.

Security note. The password you put in the label is visible in docker inspect (Docker stores labels verbatim). proximo hashes it on disk in the proxy config, not in the label. This is a dev-time tool; for a host you care about, pass a pre-hashed value so no cleartext lives anywhere.

proximo.cors — true (or 1/yes) emits permissive CORS response headers (all origins, common methods, any header) and answers OPTIONS preflight. Scope it instead with a comma-separated origin list, e.g. proximo.cors=https://app.test, to advertise only those origins.

proximo.header.<Name> — adds a custom response header. Repeat the label for several headers (proximo.header.X-Env=dev, proximo.header.X-Region=eu); they accumulate.

proximo status shows a MIDDLEWARES column listing the active middlewares per container so you can confirm what is wired.

The curated set is deliberately small. Rate limiting, retries, forward-auth, IP allowlists and the rest of Traefik's catalog are out of scope — raw traefik.* middlewares remain the escape hatch (see Native Traefik labels).

proximo.inspect — see what the browser saw

An inspected route is served through a proximo hop instead of straight to your container. The hop injects a small reporting agent into HTML responses, and records the request it served alongside whatever the browser reported while that page was live — an uncaught exception, a rejected promise, a CSP violation, the console and network breadcrumbs that led up to it, and a snapshot of the DOM at the moment it broke.

services:
  web:
    image: node:22
    labels:
      - "proximo.hosts=web.test"
      - "proximo.inspect=true"
proximo errors --host web.test

It is opt-in and never opt-out, because it is the one proximo label that changes the bytes your project sent: every other label only adds routing. It applies to HTTP routes only — a TCP (SNI) route has no response body to inject into, so the label is ignored with a warning — and it is refused for a replica set, because the hop forwards to a single backend; scale the service to one replica to inspect it.

Read Inspection for what is captured, what is deliberately not, and where the data lives.

proximo.share — share a route with colleagues

Constraints 1, 2, 3.

proximo.share is a pure switch: not required, default false, truthy true/1/yes (case-insensitive), anything else leaves sharing off — the grammar of proximo.redirect, proximo.inspect and proximo.transcript. It carries no name, no list of people and no suffix:

A shared route keeps its Bare and Qualified .test hosts, unchanged in name and in meaning. It gains two peer names, always both:

Derivation api.test in project shop
Bare peer name the declared host, with .<tld> replaced by .<machine>.<suffix> api.<machine>.<suffix>
Qualified peer name the Qualified host, with .<tld> replaced by .<machine>.<suffix> api.shop.<machine>.<suffix>

The rule is the local one with a longer suffix, so it holds wherever the local rule holds:

Everything else about the route travels with it unchanged. The peer names go to the same service through the same middleware chain — proximo.auth, proximo.cors, proximo.header.* and proximo.inspect included — so a colleague's request is the request a local browser makes, through another name. proximo.redirect applies to the peer names with identical semantics: opt-in, 302, and no :80 route without it. Replica sets are shared normally: the peer names point at the service, not at one backend.

proximo.share is per container, so a host split across containers with proximo.path is shared per container. Sharing only the container that serves /api gives a peer name that answers 404 at its root and serves /api. That is the contract working: a peer name is the union of the routes that opted in, not a proxy for the whole site.

TCP routes are not shared. A route declaring proximo.tcp.port is routed by SNI and has no HTTP layer, and a passthrough backend could not present a peer certificate anyway. The label is ignored, with a watcher warning and a proximo status row.

The label declares intent, never reachability.

proximo.share declares that this route is to be served on its peer names. Whether a colleague can actually reach it additionally depends on this machine's machine label, Peer suffix, team root and intermediate being configured, and on the mesh being up — none of which the label controls.

With any of the four missing, proximo emits no peer route: the label is set, correctly honoured, and the route does not answer on its peer names. proximo status says which value is missing. With the mesh down, proximo cannot tell, and says nothing.

An unshared route is unreachable from the mesh by construction. Without the label there is no peer route at all, so a colleague who guesses an unshared route's peer name gets exactly what a name nobody declared gets (a shared link fails with a certificate error). Removing the label withdraws the peer route at the next reconcile, with no draining and no grace period: a colleague holding the page open holds a page, not a session.

Send the Qualified peer name. proximo status prints both, because it reports facts, but the Bare peer name is the one a Collision can move.

What an app needs to be shareable

Constraints 4. proximo detects nothing here. An app is shareable when it has two properties, and the self-test below checks both:

  1. Every absolute URL is derived from the request — Host, or X-Forwarded-Host — never from a configured base URL (APP_URL, SITE_URL, ROOT_URL and the like). Traefik passes Host through unrewritten and sets X-Forwarded-Host to the peer name and X-Forwarded-Proto to https, so an app that builds URLs from the request needs no change. One built from a configured base sends a colleague's browser to a .test host that does not resolve on their machine.
  2. Cookies are host-only, with no Domain attribute. A cookie pinned to Domain=<name>.test is refused on the peer name, so the login appears to succeed and never sticks. A cookie pinned to the Peer suffix is sent to every shared route of every colleague, because the whole mesh is one site.

The self-test: open your own route's Qualified peer name in your own browser, log in, and navigate. If you stay on the peer name and the session holds, the app is shareable. It needs no colleague, because this machine's resolver reaches its own peer names like anyone else's.

A WebSocket needs no caveat: Traefik proxies the upgrade on the same route, and Origin and Host carry the same peer name, so an origin check comparing them passes.

A session started on the .test host does not follow onto the peer name, and the reverse: they are different sites. You log in once per name.

proximo.tcp.port — route TCP services by name (SNI)

HTTP routing multiplexes every host on :443 by the Host header, but raw TCP services (Postgres, Redis, MySQL, gRPC, MQTT, HTTPS backends) have no such key. Because those services speak TLS, proximo routes them by the connection's TLS SNI — the hostname in the ClientHello — on the same :443 entrypoint, so a DB is reachable by name with no extra port and no host-port collisions between parallel stacks.

services:
  db:
    image: postgres:17
    environment: { POSTGRES_PASSWORD: dev }
    labels:
      - "proximo.hosts=db.test"
      - "proximo.tcp.port=5432"     # TCP-over-TLS by SNI, no new port
docker compose up -d
# Connect on :443 (where the SNI router listens), not the backend port 5432.
# db.test resolves to 127.0.0.1; SNI db.test routes the TLS stream to the backend's 5432.
psql "postgresql://postgres:dev@db.test:443/postgres?sslmode=require"
proximo status                       # lists the TCP route + its TLS mode

Round-robin across replicas

Two or more containers declaring the same host and the same backend port — HTTP (proximo.port) or TCP (proximo.tcp.port) — are treated as replicas of one service: proximo emits a single router whose load balancer carries one server per container and distributes traffic round-robin. A lone container is unchanged (one server). Containers on the same host and the same path that differ in middleware or redirect are not merged — they still resolve deterministically as a collision (see proximo.path). proximo status marks a balanced route with (balanced ×N).

What happens behind the scenes

For each routed container the watcher:

So the hostnames you declare are exactly the ones that resolve and the ones the certificate covers — HTTPS is trusted with no browser warning. See Architecture for the reconcile loop.

Native Traefik labels (backward compatible)

Existing setups using native traefik.* labels keep working unchanged — the Docker provider stays enabled. A container is still routed when it sets traefik.enable=true and a Host(...) router rule, and the watcher issues its certificate the same way.

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.web.rule=Host(`web.test`)"
  - "traefik.http.services.web.loadbalancer.server.port=80"

Reach for raw traefik.* labels when you need features not expressible with proximo labels — middlewares beyond the curated set, exact-Path or regex rules, header matching. The common edge needs no longer require them: use proximo.path for the SPA-plus-API split and the proximo middlewares for basic auth, CORS, and custom response headers. You can mix schemes across containers freely.

Avoid declaring the same host in both schemes. If a host appears in both a proximo.hosts label and a native traefik.* router rule, proximo withdraws its own router and the native rule serves the host alone; proximo status lists the host under the container carrying the rule and reports the withdrawal (see a host collision is reported). Use one scheme per host.

Multiple networks

When a container is attached to several Docker networks, select the one Traefik should use to reach it:

labels:
  - "proximo.hosts=app.test"
  - "traefik.docker.network=<network>"

Quick reference

# Single host, auto-detected port
- "proximo.hosts=app.test"

# Multiple hosts
- "proximo.hosts=app.test, api.test"

# Explicit port (image exposes 0 or many)
- "proximo.hosts=app.test"
- "proximo.port=8080"

# Temporarily parked
- "proximo.hosts=app.test"
- "proximo.enable=false"

# Opt in to the HTTP->HTTPS redirect (off by default)
- "proximo.hosts=app.test"
- "proximo.redirect=true"

# Share one host across containers by path prefix
- "proximo.hosts=app.test"
- "proximo.path=/api"          # app.test/api/... -> this container
- "proximo.path.strip=true"    # strip /api before the backend (optional)

# Middlewares: basic auth, CORS, custom response headers (compose in order)
- "proximo.hosts=api.test"
- "proximo.auth=alice:s3cret"  # plaintext hashed on disk; or pass a $2y$ hash
- "proximo.cors=true"          # or a comma-separated allowed-origin list
- "proximo.header.X-Env=dev"   # repeatable

# TCP service by name (SNI) — e.g. a database; client uses TLS+SNI to <host>:443
- "proximo.hosts=db.test"
- "proximo.tcp.port=5432"      # default: proxy terminates TLS, plaintext to backend
- "proximo.tcp.tls=passthrough"  # optional: backend terminates TLS end-to-end

# See client-side errors, correlated with the response that caused them
labels:
  - "proximo.hosts=web.test"
  - "proximo.inspect=true"

# Share with colleagues on the team's mesh (needs this machine configured: see sharing.md)
labels:
  - "proximo.hosts=web.test"
  - "proximo.share=true"

# Advanced: native Traefik labels
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`web.test`)"
- "traefik.http.services.web.loadbalancer.server.port=80"