Appearance
Starting a project that predates the router
Older stacks publish their own fixed ports and many bind 443, which the router holds. dqx starts them anyway, and changes nothing in their repository.
bash
cd ~/projects/that-project
dqx uptext
→ pre-router stack: serving it through the router, its own file untouchedA bridge, not a migration. It disappears the day the project is converted.
dqx install refuses on one
text
✗ .dq/config.yml is not v4 - dqx has not adopted this project
Installing would write generated files beside a stack dqx does not own, and start that one.
`dqx adopt` shows what adopting writes, and writes nothing.
`dqx adopt --write` applies it, and then `dqx install` runs here.
Until then this project installs with its own script; `dqx up` starts it either way.Install is the one command that writes a project's stack, and on a project of this age it cannot finish the job: the generated files land, the hand-edit guard then declines to replace the hand-written docker-compose.yml, and the next step starts the old stack with new configuration beside it. dqx adopt is the door in, and it previews by default.
What actually happens
text
browser your machine
─────── ────────────
https://app.example.local.dolphiq.eu
│
│ :443
▼
┌───────────────┐
│ dqx router │ reads a route file per project
│ (Traefik) │ ~/.local/share/dqx/router/dynamic/<key>.yml
└───────┬───────┘
│ the router is connected to the project's own network
│ ─────────────────────────────────────────────────────
▼
┌ project network ────────────────────────────────────────────┐
│ │
│ ┌─────────┐ fastcgi ┌─────────┐ ┌─────────┐ │
│ │ web │ ─────────▶ │ app │ ──────▶ │ db │ │
│ │ (nginx) │ app:9000 │ php-fpm │ │ mysql │ │
│ └─────────┘ └─────────┘ └────┬────┘ │
│ │ │
└───────────────────────────────────────────────────┼─────────┘
│
127.0.0.1:<this project's> ▼
TablePlus, dqx db| The fixed ports go | The stack stops publishing 80, 443, 8080 and the rest, which is what lets several projects run at once. |
| The database keeps one | On 127.0.0.1, on a port derived from the project and the same after every restart, so dqx db and TablePlus keep working. |
| The router joins the project | One docker network connect, so Traefik reaches the web container by name. |
The router joins the project, not the other way round
Compose gives every container its service name as a network alias. Stacks of this age all call their services web, app and db, so on one shared network app would mean two containers and one project's nginx would resolve fastcgi_pass app:9000 into another project's php-fpm. Measured here at three requests in four, showing as File not found. on a site whose files are all present.
Connecting the router to the project's network instead leaves every project's own names meaning one thing.
Why a generated stack never had this
dqx writes fastcgi_pass <container-name>:9000, which is unique. A legacy project's nginx config is its own and dqx does not rewrite it.
TLS, where the stack speaks it
A stack that publishes 443 terminates TLS itself, and the router speaks TLS to it. Not for secrecy on a local hop: that nginx sets fastcgi_param HTTPS from its own listener, so reached over http it reports off and the application builds http:// asset URLs on an https:// page, which the browser blocks.
The certificate on that hop is not verified. It is a connection between two containers addressed by name, and no project certificate carries that name. A stack with no 443 listener is reached over http.
Domains the config never mentioned
A stack this old keeps its domains in its nginx configuration. Measured on one project here: sevenserver_name entries against two declared repositories. dqx routes and certifies both lists, so the five nobody wrote down are reachable too. dqx doctor still names them, because a domain a config does not know about is one a migration would leave behind.
The certificate
dqx up reissues this project's certificate when it does not name every domain, including on a project dqx has not adopted: its config names no sites, so the domains are read off its stack.
Restarting the router
A container restart drops every network joined at runtime, so a restarted router loses its way into every project it served this way and they all answer 502 at once. dqx router:up puts them back:
text
back on 7 pre-router projectsWorked out from what is running rather than from a list: a web container not on the shared network is one the router can only reach by joining its own.
Starting one after dqx kill
dqx kill takes the router down, and a project served through it has no host ports for the web server at all. So dqx up starts the router for any docker project, not only one whose stack declares it:
text
→ starting Docker Desktop
→ shared router
→ pre-router stack: serving it through the router, its own file untouchedWhat the files are called
Both are named after the project, not the client:
text
~/.local/share/dqx/overrides/<project-id>.yml
~/.local/share/dqx/router/dynamic/<key>-<project-id>.ymlMeasured here: 47 projects share 30 client keys, and one key belongs to six directories. With the key alone, stopping the copy you were not using removed the route the one you were using needed.
Each route names its project in its first line, so dqx can drop one whose project has moved or been deleted:
yaml
# project: /Users/you/projects/that-projecttext
dropped 1 route whose project is gone
back on 2 pre-router projectsdqx down removes the route as well as the override, matched by the container it points at. A route left behind can shadow another project that declares the same domain, so a working project is unreachable while Traefik reports its backend as UP.
Which file is changed
Not the project's. docker-compose.yml in the repository is byte for byte what its last commit says.
The change is a compose override in dqx's own directory:
yaml
# ~/.local/share/dqx/overrides/<project-id>.yml
services:
web:
ports: !reset [] # stop publishing 80, 443, 8080
expose: !reset []
app:
ports: !reset []
expose: !reset []
db:
ports: !override
- "127.0.0.1:20675:3306" # this project's own, the same every restart
expose: !reset []
command: !override
- --sql-mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION # the stack's own, verbatim
- --performance-schema=OFFdqx then runs every compose command for that project as:
bash
docker compose -f docker-compose.yml -f ~/.local/share/dqx/overrides/<id>.yml …!reset and !override are compose's own tags
Compose merges lists by appending, so ports: [] would leave every original binding in place. !reset clears the key; !override replaces it.
expose: is cleared for a second reason: several stacks of this age write expose: ['3306:3306'], a mapping form that key never took. Compose then refuses to create the container with invalid start port '3306:3306': invalid syntax, so those projects cannot start at all.
Why the database gets a command
performance_schema costs 229 MB of an idle mysql:8.0: 361 MB against 132 MB with it off. A generated project turns it off in its own mysql_extra.cnf; a pre-router stack has no such file, so the override is the only place left. One real project went from 386 MB to 137 MB on the next dqx up.
The flags the stack already passes are written out again because compose replaces command rather than merging it. Losing a stack's --default-authentication-plugin=mysql_native_password that way is how its older clients stop connecting.
The stack always wins. Name performance_schema anywhere the server reads and dqx adds nothing, which is also how you get it back for an afternoon of restoring a staging dump:
yaml
command: --performance-schema=ON --sql-mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTIONOnly MySQL. MariaDB has it off already, and PostgreSQL would exit at boot on a flag it does not know.
A subnet two projects both want
A stack of this age may pin its network:
yaml
networks:
example_network:
ipam:
config:
- subnet: 172.20.0.0/16Docker hands out 172.17 upward to every project that pins nothing, so one that started earlier can be sitting in the block this one requires:
text
failed to create network example_network: Error response from daemon:
invalid pool request: Pool overlaps with other one on this address spacedqx cannot route around this the way it routes around ports: the pinned subnet usually comes with ipv4_address and extra_hosts pointing at it, so dropping it breaks the application's calls to itself.
Move what docker hands out automatically instead, once, in ~/.docker/daemon.json:
json
{
"default-address-pools": [
{ "base": "10.200.0.0/13", "size": 24 }
]
}Restart Docker Desktop and dqx down in each project to remove networks created before the change. Every hand-pinned subnet in 172.x is then free.
Check the range against your own network first
Pick a base nothing else on the machine routes to. On a laptop whose LAN is 192.168.x and with no VPN in 10.200-10.207, the block above is free.
How it is put back
dqx down undoes all three, in an order that matters:
- Disconnect the router from the project's network. Compose cannot remove a network that still has a member.
- Stop the containers, with the override still in place. A
downaddressing a differently shaped stack thanupbuilt leaves containers behind. - Delete the override and the route file.
After that the project is exactly what a fresh clone would be.
If down does not finish
Everything dqx added is one of three things you can remove by hand.
A network that will not go away, because the router is still attached:
bash
docker network disconnect project_name_network dqx_router
docker network rm project_name_networkA leftover override, which only affects dqx's own compose calls:
bash
rm ~/.local/share/dqx/overrides/<project-id>.ymlA leftover route, which is what answers 502 for a domain no longer served:
bash
rm ~/.local/share/dqx/router/dynamic/<client-key>.ymlRunning dqx down again is fine and does the same thing.
Can it still be started the old way
Yes. The override is only ever passed by dqx, with -f. Plain docker compose reads docker-compose.yml and nothing else, and so does the older dq tool.
bash
dqx router stop # or: docker stop dqx_router
cd ~/projects/that-project
docker compose up -d # binds 443, 8080 and the rest, as it always did- The router has to be stopped first. It holds 80 and 443.
- A stack with
expose: ['3306:3306']will not start this way at all, and did not before dqx existed either. For those, dqx's override is the only thing that starts them.
To go back:
bash
docker compose down
dqx router start
dqx upGit hooks
A project older than v4 installs its own pre-commit hooks from localbuild/install_git_pre_commit_hooks.sh, gated by installGitHooks. Both halves keep working.
Before adopting. The hook is a file in .git/hooks/pre-commit and git runs it whatever dqx does. Serving the project through the router changes no container name and no path.
After adopting. dqx adopt carries the setting over (installGitHooks: true becomes install.gitHooks: true) and installs the hooks itself, in every site and package checkout:
- where there is none, it writes dqx's;
- where the old tooling's hook is, it replaces it and keeps the old one as
pre-commit.bak. Its commands are already inhooks.preCommit, read from that very hook; - a hook it cannot read stays, with the flag that replaces it.
The preview names each of them under Git hooks, with --write:. dqx install writes a hook only where a checkout has none yet.
The hook dqx writes names no backend, so it survives the project moving to another one:
bash
dqx exec --site api.example.local.dolphiq.eu -- vendor/bin/pint ${STAGED_PHP}dqx githooks is the command that says what is there and changes it deliberately:
bash
dqx githooks --dry-runtext
crm.example.local.dolphiq.eu wrote a pre-commit hook
mijn.example.local.dolphiq.eu has its own hook - `dqx githooks --force` replaces it, keeping a .bak
app.pt.example.local.dolphiq.eu wrote a pre-commit hookdqx githooks | writes where there is none, and rewrites its own from the config as it is now |
--site <site> | one site, named by its domain or its checkout directory, and no package |
--force | replaces a hook dqx did not write, keeping the previous one as pre-commit.bak |
--off | removes the hooks dqx wrote, and only those |
--dry-run | says all of the above and changes nothing |
Why the hook commands are a list, not switches
A site declares its own in hooks.preCommit:
yaml
sites:
- domains:
primary: api.example.local.dolphiq.eu
hooks:
preCommit:
- vendor/bin/pint ${STAGED_PHP}
- node_modules/.bin/eslint --fix ${STAGED_JS}Measured across this fleet: 43 checkouts carry a hook, all 43 run Pint, 20 run Prettier and two run ESLint. A fixed set of switches is already wrong for those two, and it would put tool names inside dqx that belong to the projects. Three variables carry the file lists instead:
${STAGED_PHP} | the staged .php |
${STAGED_JS} | the staged .js, .jsx, .ts, .tsx, .vue |
${STAGED_FILES} | everything staged |
A command naming one of them runs only when that list has something in it. Without that, committing a single .js file ran vendor/bin/pint with no arguments, which formats the whole checkout.
dqx adopt reads an existing hook and writes the commands it implies into hooks.preCommit, so a project's own tuning survives the move:
text
api.example.local.dolphiq.eu: pre-commit 2 command(s) from its own hookA hook mentioning no tool dqx recognises is left alone. dqx githooks replaces the old tooling's hook only with --force; dqx adopt replaces it because it has just read its commands into the config. --off on somebody else's hook says its hook is not dqx's to remove.
Other hooks than pre-commit, the team's own scripts, and switching hooks off per site or package: see git hooks.
Why this is not part of dqx sync
sync writes what a backend needs from the config: the stack, the nginx confs, the route. A git hook is tooling inside a checkout, and sync --force quietly replacing a hand tuned hook is exactly the surprise that flag must not carry.
A proxied site on a stack that predates the router
sync writes a site's proxy_pass into docker/conf/nginx/sites-*.conf. A stack that mounts its own nginx.conf never reads those files, so its old server block keeps answering for the domain, often with a 200 from a built dist/.
sync, up and doctor ask the web container (nginx -T) whether it proxies each proxied site, and say so when it does not. verify fails such a domain even when it answers 200. The fix is converting the project, below.
Converting it for good
bash
dqx adopt --write # keep whatever dqx does not generate
dqx sync --force # converts it while the router runs; --router converts it anyway
dqx down && dqx upThat rewrites the stack and the nginx config in the shape dqx maintains, the override is never written again, and everything on this page stops applying to that project. A fixed-port stack beside a running router would publish 443 where the router listens, so sync converts it then and says so; dqx sync --no-router keeps the fixed ports.
sync and up remove the old override and its route (~/.local/share/dqx/router/dynamic/<key>-<project-id>.yml) once the stack joins the router itself. Left behind, that route points at the old nginx on 443 with the same priority as the new route, and Traefik may keep serving it: every domain answers 502.
What adopt reads to write the config
Beside the stack and the nginx configs, dqx adopt reads:
- Each checkout's git. A site's
repositoriesare the repositories actually found in its checkout, at its root and up to two levels down, each with itspathand the branch it is on: a legacy checkout often holds a base and an app repository side by side. A second site served from the same checkout clones nothing of its own. localbuild/local_install.sh. Itsgit clonelines fill in a checkout that is not on disk yet.ln -s,mkdir -panddocker exec -w /var/www/<checkout>/<dir> … bash -c "<command>"become the site'shooks.postInstall(cd <dir> && <command>); the same inwww/itself becomes the project's. A line it cannot place, such ascp -n localbuild/…, is listed underonly by hand, never dropped.- The database.
MYSQL_DATABASEof the olddbservice, and the.sqland.shfiles in its init directory, givedatabase.schemasand each site'sdatabase. The service's--flag=valuecommand becomesdatabase.settings, and a change of image on a data folder that exists is said, withdqx snapshotas the way back. - Config templates. A
cp -n localbuild/… www/<checkout>/…line becomes the site'sconfigFiles, whichdqx installcopies in where the file is missing. - What the old stack built.
links:is dropped, and so is awebimage that is onlyFROM nginx:<tag>: dqx serves nginx itself. Dockerfiles nothing builds any more are named. - The install steps.
install.composer,npmandenvare proposed only when some checkout has acomposer.json,package.jsonor.env.exampleat its root.
What adopt does beside the config
dqx adopt previews all of this and --write applies it. Run it again on a project that is already adopted and it still does these steps, or says Nothing to do.
.dq/config.ymlkeeps its comments. The config is written anew, and every comment follows its key or list entry wherever that moved to. A comment whose key is gone is named underComments whose key is gone, so you can put it back by hand.dqx migratedoes the same.install.shis written, executable, if the project has none: the line the README tells a new developer to run. It checks that dqx is installed and at least the config'srequires, then hands over to it.dqx syncrewrites it whenrequiresmoves.The README gets an install block at the top:
bashgit clone git@ssh.bitbucket.org:acme/acme-docker.git acme cd acme ./install.shIt clones into the client key, the name the team calls the project by, and says how to get dqx on a machine without it.
dqx synckeeps that block current; the rest of the README is the team's.The README's old instructions are proposed for removal. A section documenting
dqcommands,localbuild/ordocker execgoes, with its subsections, and a link to one of the project's own domains loses:8080. The preview lists each change; the title and everything else stays. It is applied toREADME.mditself, so read it in your diff before committing.Git hooks are installed as described above.
Agent files in each site's repository: dqx's block in
AGENTS.mdandCLAUDE.md, its entry in.mcp.json, andBOOST_PHP_EXECUTABLE_PATHtaken out of the.envfiles (.env.exampleincluded, since a fresh clone copies it). The preview lists each file underAgent files, with --write:, by repository, and marks a file git tracks with(tracked, commit it).dqx syncmakes the same changes.
Which domain becomes the primary
An old config names one domain per repository and lists the others as aliases, and that one is not always a domain the application answers on. dqx adopt reads APP_URL from the checkout's .env (or .env.example): when it names one of the aliases, that alias becomes the primary, the old one stays an alias, and directory: keeps the checkout where it is. dqx doctor says the same about a project that is already adopted, and both doctor and dqx install stop when a primary was changed by hand without directory:, before a second copy is cloned under the new name.