Appearance
The shared router
Every project serves at once, on 80 and 443, each on its own domain. One reverse proxy in front of all of them makes that possible, and dqx owns it.
You rarely touch it. dqx up starts it if it is not running.
Why it exists
Ports 80 and 443 have one owner per machine. A project whose stack binds 443:443 takes the machine, and the second project to start gets a port conflict instead of a site.
So project stacks publish nothing on those ports, and the proxy holds them for everyone.
Everything else still gets a host port, just not the usual one: the database is not on 3306, because only one project could have it. Each gets a port derived from the project, the same after every restart. dqx info and dqx db --url print them, and ports: asks for an exact number.
What it is
Traefik, in one container, in its own compose project:
| container | dqx_router |
| compose project | dqx-router |
| image | traefik:v3.3 |
| stack | ~/.local/share/dqx/router/docker-compose.yml, generated by dqx |
| routes | ~/.local/share/dqx/router/dynamic/<client key>.yml, one file per project |
| certificates | ~/.local/share/dqx/router/certs/, one per project |
| network | dqx_router, which every project's containers join |
| dashboard | http://127.0.0.1:8088, on loopback so nothing on the network reaches it |
How a project reaches it
text
browser your machine
─────── ────────────
https://app.example.local.dolphiq.eu
│
│ :443 this project's certificate, picked on SNI
▼
┌───────────────┐
│ dqx router │ ~/.local/share/dqx/router/dynamic/<key>.yml
│ (Traefik) │ rule: Host(`app.example.local.dolphiq.eu`)
└───────┬───────┘ service: http://<key>_web:80
│
│ dqx_router (shared network)
▼
┌ project ────────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ fastcgi ┌─────────┐ ┌─────────┐ │
│ │ web │ ──────────────▶ │ app │ ───▶ │ db │ │
│ │ (nginx) │ <key>_app:9000 │ php-fpm │ │ mysql │ │
│ └─────────┘ └─────────┘ └────┬────┘ │
│ no host also on │ │
│ ports at all dqx_router │ │
└─────────────────────────────────────────────────────┼───────┘
│
127.0.0.1:<this project's> ▼
TablePlus, dqx db| The web container publishes nothing | The router reaches it over the shared network. |
| Everything else gets a port of its own | ${FORWARD_DB_PORT:-20675}:3306, so two projects never want the same number and neither moves. |
| nginx talks to a container name | fastcgi_pass <key>_app:9000, never app. On a shared network every project's container is also aliased app, and that name resolves into whichever project answered first. |
| The app joins the network too | So one site can call another on its real URL. |
TLS stops at the router, so nginx and php-fpm see plain http. dqx writes a dqx-maps.conf that turns Traefik's X-Forwarded-Proto into fastcgi_param HTTPS, which stops the application building http:// asset URLs on an https:// page.
Routes are files, not labels
Traefik's usual docker provider reads the engine socket. dqx writes a file per project instead and Traefik watches the directory, so the router mounts no docker socket: no privilege, and the same behaviour on every engine.
Each route file also declares a middleware that adds a response header to everything its routers answer:
yaml
http:
middlewares:
example-a1b2c3d4-marker:
headers:
customResponseHeaders:
X-Dqx-Router: "example"X-Dqx-Router is how dqx tells its own answers from another server's (Herd, lerd) that listens on the same ports: the value is the project key, and a reserved host, probe.dqx.invalid, answers with probe so the router can be asked without touching a real project.
One consequence worth knowing: a route outlives the project it points at, so a stopped project answers 502 rather than 404. dqx verify tells the two apart.
Where the generated files live
.dq/docker-compose.yml and docker/conf/nginx/ in the project, gitignored and rewritten by dqx sync. The route is not in the project: a route left in a repository would outlive the checkout that describes it.
Which route wins
Traefik's default priority is the length of the rule, which makes the winner an accident of how long a project's domains are. dqx writes the priority explicitly and splits exact hosts from wildcards:
yaml
example-a1b2c3d4:
rule: Host(`api.example.local.dolphiq.eu`)
priority: 100000
example-a1b2c3d4-wildcard:
rule: HostRegexp(`^.+\.api\.example\.local\.dolphiq\.eu$`)
priority: 10000An exact host is more specific than a pattern, so it always wins. Both numbers sit above any rule length dqx can produce, so a route file from an older dqx never outranks either.
dqx doctor --all names overlaps it can see:
text
domains ! *.mijn.example.local.dolphiq.eu (example) also covers shop.mijn.example.local.dolphiq.eu (other)A warning rather than a fault, because the priority settles it. Still worth knowing: stop the project serving the exact domain and the wildcard starts answering for it, and a stranger's 404 reads exactly like your own.
Two route files claiming the same exact host are a fault: they have the same priority, so Traefik serves only one of them, and it may be the stale one. dqx up and dqx doctor name both files.
Certificates
One per project, carrying that project's domains and nothing else, issued by mkcert against the local CA your system already trusts. That is why https:// on a local domain is green.
text
~/.local/share/dqx/router/certs/<key>-<project id>.crt
~/.local/share/dqx/router/dynamic/tls-<key>-<project id>.ymlTraefik's TLS store picks on SNI, so each project contributes an entry and a browser is handed that project's certificate.
dqx up reissues only when there is something to do: a domain the certificate does not cover, or under seven days of validity left. mkcert costs 0.4 s; the reload after it drops connections in flight, so a start that changes nothing reloads nothing.
dqx router:up does every project dqx knows in one command, and drops certificates of projects that are gone from disk. dqx doctor reports how long one has left and how many names it carries.
Wildcards go one level deep
A certificate for *.example.dqx covers app.example.dqx and does not cover a.b.example.dqx.
Why not one certificate for the machine, which is what dqx used to write
Three reasons, all measured on this fleet:
- One mkcert call over eighty domains is all-or-nothing. One project's nginx carried a regular expression in a
server_name, mkcert refused the whole batch, and no certificate was issued. One odd line took TLS from forty-six projects. - It only ever grew. Names were merged with what the file already had, so it carried a doubled domain from a broken
server_nameand names of projects deleted months earlier. - An unknown host got a valid certificate. The shared file was the store's
defaultCertificate, so any name reaching the router was answered with a certificate covering eighty other domains. With no default, Traefik answers an unknown SNI with one it generates itself, which is what a browser should warn about.
Coming from that version? Nothing to do by hand. The first dqx up issues the project's certificate and takes its names off the shared one, which is the step that makes the new certificate take effect: two certificates carrying one name is a tie Traefik resolves by serving the default. While any project still lacks its own, the shared certificate is issued for the remainder. Once that is empty, dqx router:up deletes dqx.crt, its key and tls.yml:
text
Every project has its own certificate now - the shared one is gone.When a project goes
dqx down removes a legacy project's route, and dqx clean wipes its data, but both leave the project's certificate, its TLS entry and its profiles in place: you are expected to start it again. dqx remember --off is the one that means "forget this project", and it takes everything dqx keeps for it outside its directory:
text
~/.local/share/dqx/router/dynamic/<key>.yml
~/.local/share/dqx/router/dynamic/<key>-<project id>.yml
~/.local/share/dqx/router/dynamic/tls-<key>-<project id>.yml
~/.local/share/dqx/router/certs/<key>-<project id>.crt and its .key
~/.local/share/dqx/debug/<key>/
the project's entry in ~/.local/share/dqx/domains.jsonIt prints each path it removes, and a second run finds nothing. Files are matched by the exact key and project id, so forgetting zz leaves zzqct alone. <key>.yml and debug/<key> are named by the client key only, which every checkout of that client shares: while another project on the machine has the same key they stay, and the output says so. mkcert's root CA, the router's own tls.yml and transports.yml are never touched.
No restart. Traefik watches router/dynamic, so a route or TLS entry that disappears stops being served once Traefik reads the directory again; the running router keeps every other project's connections.
On macOS, Traefik does not notice a deleted file through Docker Desktop's bind mount: the old route stays served until another file in the directory changes. Whenever dqx removes a file there, it rewrites transports.yml (same content) afterwards, which makes Traefik reload. A file you delete by hand needs the same nudge: touch ~/.local/share/dqx/router/dynamic/transports.yml.
A directory still sitting in a tree dqx scans
dqx router:up issues a certificate for every project directory in the trees your remembered projects live in, remembered or not. Forget a project whose directory is still beside them and the next router:up issues it one again. Move or delete the directory to be rid of it for good.
One backend at a time
DDEV has its own proxy, Lerd its own nginx, Herd its own, and each wants 80 and 443. On the same address, two backends cannot serve at the same time - which is the default, and what dqx up below is about. On different addresses they can: see running beside Herd or DDEV.
bash
dqx kill # frees 80 and 443, whichever backend was holding them
dqx kill --dry-run # what that would stop, firstWithin docker the opposite holds: every project runs together behind the one router.
dqx up clears the way itself, stopping only what is actually in the way and never the backend this project runs on:
text
→ shared router
→ lerd holds 80 and 443, and this project needs them for docker
→ stopping lerd
lerd is stopped. Start it again with: lerd startIf the ports cannot be freed, nothing starts and the reason is on screen. The router is judged by what it publishes rather than by whether the container is up, so one bound to neither port is recreated:
text
router ! up and bound to neither 80 nor 443, lerd holds them - `dqx up` recreates itThe dashboard says the same in a banner across the top, with the command that frees the ports and a Recheck button.
Running beside Herd or DDEV
- Herd and Valet keep
127.0.0.1, and dqx moves:dqx loopback 127.0.0.2. Both then serve 80 and 443 at once. Walked end to end in next to Herd, Valet or Sail. - Laravel Sail moves its own port with
APP_PORTin its.env- the same page. - DDEV moves its ports, and its URLs carry them:
ddev config global --router-http-port=8080 --router-https-port=8443
The router listens on 127.0.0.1 by default - this machine only - and dqx's resolver answers dqx's suffixes with whatever address the router is on, with no IPv6 answer for a single IPv4 address, so no browser waits on ::1. dqx config machine routerBindAddress 0.0.0.0 puts it on every address again, the LAN included.
A stack that predates the router
Older stacks publish their own fixed ports and many bind 443. dqx serves them anyway without touching their repository, by a different arrangement: see Starting a project that predates the router.
Looking at it
bash
dqx info # this project's URLs and addresses
dqx verify # what every declared domain actually answered
dqx doctor # the certificate, the routes, who holds the ports
docker logs dqx_router