Appearance
Docker Compose
The stack dqx is built on, and the one every other feature assumes.
What dqx writes
The whole stack: the app container, nginx, the database, and whatever services the project declares. Generated from .dq/config.yml, so it is disposable - delete it, run dqx sync, get the same thing back.
Generated files live under .dq/ in a single-repository project, or beside the wrapper in a multi-repository one, and they are gitignored. The declaration is the only thing committed.
Every generated file starts with a # generated by dqx line. dqx sync replaces a file that carries it and keeps the previous one as .bak; delete the line and the file is yours, never replaced again. A site conf the old dq tool wrote carries no line at all, so dqx compares it with what that tool would have written for the site: byte for byte the same means nobody touched it, and sync replaces it too (with a .bak). One changed character and it is treated as your edit.
What a site's server block can say without taking it over is in nginx and headers.
Ports
Nothing binds its service's usual port except the shared router, which owns 80 and 443. The database, redis and mail each get a port of this project's own instead - derived from it, in the range 20000-29999, and the same after every restart. Two projects running together never collide, and a saved connection keeps working.
dqx info and dqx db --url print them, and ports: asks for an exact one.
What it costs
File access is a bind mount, which on macOS is the slow path. A large composer install or a Vite build feels it.
Docker Desktop holds memory it does not hand back until it restarts. dqx memory shows what each project is using and what the VM is holding beyond that.
Try OrbStack before switching backend
Same engine, same commands, nothing in the project changes. Measured on one machine, both engines, the same 48,108-file project tree, warm:
| Docker Desktop | OrbStack | ||
|---|---|---|---|
| walking the tree | 1,780-1,856 ms | 401-430 ms | 4.2× |
| reading 3,000 PHP files | 450-465 ms | 88-89 ms | 5.1× |
du over vendor | 1,422-2,355 ms | 341-349 ms | 4.2× and better |
Not the old comparison against Docker Desktop's FUSE mount: that machine runs VirtioFS and the Virtualization framework, the same modern base OrbStack builds on. What OrbStack adds is its own caching on top.
The ceiling is the boundary itself. The same tree inside a VM walks in tens of milliseconds, so both engines pay for going between macOS and Linux and one pays less. If that remaining gap is still your bottleneck, DDEV is the answer, at the cost of much slower starts.
What that means for a real project, rather than a syscall
The heaviest project in one fleet: four sites, code on a bind mount, PHP and MySQL and Elasticsearch and redis.
| Docker Desktop | OrbStack | ||
|---|---|---|---|
dqx up | 31.7-31.9 s | 31.5-31.7 s | the same, and neither was the engine |
dqx down | 2.3-2.4 s | 3.1-3.3 s | Desktop, by a second |
| a warm page | 26-34 ms | 25-37 ms | the same |
| the first page after a restart | 426-458 ms | 228-301 ms | 1.8× |
composer dump-autoload -o | 4.8-6.6 s | 2.6-4.7 s | 1.4-1.8× |
The syscall gap is real but not the whole story: a warm page is served out of opcache without touching the mount. Where it shows is everything cold - the first request after a restart or a cache clear, and composer.
The dqx up row was not measuring the engine, and is no longer true of either. Both columns waited on the same clock: a healthcheck stating no interval is probed every 30 seconds by docker's default. Measured on one site with MySQL and redis:
dqx up | what it was waiting for | |
|---|---|---|
| before | 32.0 s | redis' first probe, 30.07 s after the container started |
| intervals stated | 6.99 s | MySQL, genuinely |
| today | 1.95 s | nothing - the site answered 200 a second later |
Two changes. Every healthcheck dqx generates now states interval, start_period and start_interval, so redis' first probe lands at 305 ms instead of 30 seconds, and an existing project picks that up on its next dqx sync. And dqx up returns once the containers are started rather than once they are healthy.
dqx up --wait keeps the old behaviour for a script that runs a command straight after: MySQL was healthy at t+6 s in that run, so dqx up && dqx artisan migrate on a cold stack wants the flag. dqx install, dqx switch, dqx link and the MCP tools wait whether or not it is given, because each runs the next step itself and an agent has no eyes to notice a race.
Choosing between OrbStack and Docker Desktop
The switch is docker's own, not dqx's:
bash
docker context use orbstack # to OrbStack
docker context use desktop-linux # back to Docker Desktop
docker context show # which one is currentThere is nothing to tell dqx. Which application provides docker is read from the docker context, so dqx up starts OrbStack with orb start and Docker Desktop with docker desktop start. A setting in dqx would be a second place that can be wrong while the machine says otherwise. dqx doctor and dqx info --json both say which one you are on.
What comes with you, and what does not
The two are separate virtual machines, and that splits three ways:
- Containers and images do not move. They are rebuilt or re-pulled on the other side. Measured at one switch: 19 containers on Docker Desktop, 0 on OrbStack.
- Named volumes do not move. Dump them with
dqx snapshotfirst or they are gone. In this fleet that is 1 project in 32. - Bind-mounted data directories do come with you. They live in the project, and neither VM owns them. The other 31 keep their databases across the switch.
So for almost every project here the switch is:
bash
docker context use orbstack
dqx up # re-pulls the images, reuses the data already on diskA floating tag is not the same image on both engines
Each engine keeps its own image cache, so redis:alpine is whatever that engine pulled last. OrbStack's redis 8.10.1 wrote dump.rdb in RDB format 15, and Docker Desktop's older redis:alpine would not start on it:
text
# Can't handle RDB format version 15
# Fatal error loading the DB, check server logs. Exiting.The data was fine. The reader was older. docker pull redis:alpine on whichever engine lags settles it, and pinning the tag stops it happening again. This fleet carries 44 floating references.
What it does better than the rest
- Several sites in one project, each with its own domain, docroot and PHP version.
- Complete isolation. Two projects on different PHP versions with different extensions is a configuration, not a conflict.
- It is the same everywhere. The one backend that behaves identically on a colleague's Linux machine and on CI.