Skip to content

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 up
dqx up
text
→ pre-router stack: serving it through the router, its own file untouched

A 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 goThe stack stops publishing 80, 443, 8080 and the rest, which is what lets several projects run at once.
The database keeps oneOn 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 projectOne 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 projects

Worked 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:

dqx repair
text
→ starting Docker Desktop
→ shared router
→ pre-router stack: serving it through the router, its own file untouched

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

Measured 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-project
text
  dropped 1 route whose project is gone
  back on 2 pre-router projects

dqx 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=OFF

dqx 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_SUBSTITUTION

Only 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/16

Docker 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 space

dqx 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:

  1. Disconnect the router from the project's network. Compose cannot remove a network that still has a member.
  2. Stop the containers, with the override still in place. A down addressing a differently shaped stack than up built leaves containers behind.
  3. 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_network

A leftover override, which only affects dqx's own compose calls:

bash
rm ~/.local/share/dqx/overrides/<project-id>.yml

A leftover route, which is what answers 502 for a domain no longer served:

bash
rm ~/.local/share/dqx/router/dynamic/<client-key>.yml

Running 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 up

Git 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 in hooks.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-run
text
  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 hook
dqx githookswrites 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
--forcereplaces a hook dqx did not write, keeping the previous one as pre-commit.bak
--offremoves the hooks dqx wrote, and only those
--dry-runsays 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 hook

A 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 up

That 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 repositories are the repositories actually found in its checkout, at its root and up to two levels down, each with its path and 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. Its git clone lines fill in a checkout that is not on disk yet. ln -s, mkdir -p and docker exec -w /var/www/<checkout>/<dir> … bash -c "<command>" become the site's hooks.postInstall (cd <dir> && <command>); the same in www/ itself becomes the project's. A line it cannot place, such as cp -n localbuild/…, is listed under only by hand, never dropped.
  • The database. MYSQL_DATABASE of the old db service, and the .sql and .sh files in its init directory, give database.schemas and each site's database. The service's --flag=value command becomes database.settings, and a change of image on a data folder that exists is said, with dqx snapshot as the way back.
  • Config templates. A cp -n localbuild/… www/<checkout>/… line becomes the site's configFiles, which dqx install copies in where the file is missing.
  • What the old stack built. links: is dropped, and so is a web image that is only FROM nginx:<tag>: dqx serves nginx itself. Dockerfiles nothing builds any more are named.
  • The install steps. install.composer, npm and env are proposed only when some checkout has a composer.json, package.json or .env.example at 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.yml keeps 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 under Comments whose key is gone, so you can put it back by hand. dqx migrate does the same.

  • install.sh is 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's requires, then hands over to it. dqx sync rewrites it when requires moves.

  • The README gets an install block at the top:

    bash
    git clone git@ssh.bitbucket.org:acme/acme-docker.git acme
    cd acme
    ./install.sh

    It 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 sync keeps that block current; the rest of the README is the team's.

  • The README's old instructions are proposed for removal. A section documenting dq commands, localbuild/ or docker exec goes, 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 to README.md itself, 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.md and CLAUDE.md, its entry in .mcp.json, and BOOST_PHP_EXECUTABLE_PATH taken out of the .env files (.env.example included, since a fresh clone copies it). The preview lists each file under Agent files, with --write:, by repository, and marks a file git tracks with (tracked, commit it). dqx sync makes 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.

dqx v1.78.0