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.
| 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.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
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:
api.example.com) — the local
resolver answers for <tld> only, so a qualified form of it would never
resolve.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.testan app served atshop.testcan setDomain=shop.testand be sent that cookie by every qualified host of the project — whereasapi.testanddb.testare isolated origins, because browsers refuseDomain=testfor a single-label TLD. Inside one project that is usually what you want; it is a deliberate trade (ADR 0003).
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
app.test,, ,api.test → app.test, api.test)..test) so DNS resolves them.app.test, api.test in project shop is served on four names.api.shop.test) is
allowed and wins: proximo withdraws its own generated name and reports the
withdrawal in proximo status.Reserved host.
traefik.<tld>(e.g.traefik.test) is reserved for the stack's own Traefik dashboard and must not be claimed viaproximo.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 statuswith 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.
The backend port is resolved as:
proximo.port if set, elseproximo.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
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.
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=trueto keep it; the one-line fix is the label above. This takes effect on the nextproximo update/up.
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
proximo status lists it as
starting (waiting for healthy) — recognized and opted in, just not serving
yet — so "not ready" is distinct from "misconfigured/absent".health_status events, so the route appears
within moments of the container turning healthy, not at the next 30s resync.proximo.health=false (truthy off-values false/0/no) when
the healthcheck is stricter than "can serve HTTP" (e.g. it waits for a warm
cache); the container then routes on running regardless of health.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 statusshows the container asstarting(not silently missing);proximo.health=falseis the escape hatch.
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.path=/api matches
/api, /api/, /api/users, etc. (proximo.path=/ is equivalent to no path.)/; an invalid value skips the container and
logs a warning (see
where to read watcher warnings)./api/v2 beats /api
beats a bare host — so you never tune Traefik priorities by hand.proximo.path.strip=true removes the matched prefix before the request reaches
the backend (off by default, since many backends expect the full path).proximo.path (the frontend above) keeps matching
all paths for its hosts, so it naturally serves as the fallback.proximo status — the same host-by-host resolution as any other
collision.proximo status lists each container with its prefix in the URL
(https://app.test/api), so you can see the split at a glance.
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).
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.
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
db.test:443 with SNI db.test (e.g. sslmode=require).
Plain TCP without TLS/SNI has no key and is not supported (nor is UDP) —
publish those directly with -p and use the free *.test DNS name.proximo.tcp.tls=passthrough when the backend must terminate TLS itself.proximo.hosts routes by SNI to the
declared port. Since SNI carries only the hostname, give each TCP service its
own host; declaring several ports on one host cannot be disambiguated by SNI.:443.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).
For each routed container the watcher:
http://<container-name>:<port>,
or, for a TCP-labeled container, a TCP router matching HostSNI(<host>) + service
targeting <container-name>:<port> on the shared :443 entrypoint,127.0.0.1.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.
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.hostslabel and a nativetraefik.*router rule, Traefik sees a duplicate router across providers; the watcher logs a warning (see where to read watcher warnings). Use one scheme per host.
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>"
# 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"
# Advanced: native Traefik labels
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`web.test`)"
- "traefik.http.services.web.loadbalancer.server.port=80"