Appearance
Configuration
.dq/config.yml is the only file you maintain by hand. Everything else dqx writes is generated from it and gitignored.
.dq/ is shared with the tool that came before dqx
The directory name is the same for every version, so the file says which one a project is on: v4 is lowercase with a sites: list, v2 and v3 carry an uppercase VERSION:, and v1 has no version key at all.
dqx reads all of them. dqx doctor names the dialect when it is not v4, and dqx migrate rewrites an older one after showing what changes.
yaml
version: 4
domainSuffix: example.dqx
client:
key: my_app # the container prefix
name: My App
sites:
- domains:
primary: my-app.dqx
aliases: [www.my-app.dqx]
php: '8.4'
node: '22'
publicDir: public
database: my_app
repositories:
- repository: my-app
branch: main
services:
- name: redis
image: redis:7.4
- name: mailpit
image: axllent/mailpit:v1.21
database:
type: mysql
version: '8.4'
install:
gitHooks: true
env: true
composer: true
npm: true
ssl: trueProject level
| key | what it does | when absent |
|---|---|---|
version | The config format. 4 is current | required |
client.key | Prefixes container names and the default schema | required |
client.name | Human-readable, used in generated documents | required |
domainSuffix | The suffix this project's domains sit under | local.dolphiq.eu |
clone | Written by dqx clone into a copy: which project it copies, its segment, whether it is temporary, who made it and what for | not a copy |
layout | single or multi - where the code sits | multi |
runtime | Pin a backend for this project | the machine's choice |
services | Which services to run, each with the image it runs: - {name, image}. A bare list of names is what dqx init and a site's own services: take, not this key | none |
database | Type and version | derived from the stack |
install | Which install steps run | everything but flux |
scaffold | The recipe this project was created from | none |
repositories | Code the project needs that belongs to no site | none |
hooks.postInstall | Commands run after every install, project-wide | none |
compose | Compose keys per service, merged last over what dqx generates | none |
ports | Host ports this project wants by number, keyed as dqx info names them | derived per project |
requires | The oldest dqx that reads this correctly - written by dqx | derived |
distro | The distro dqx picks for a dqx-php image it chooses (a new site, dqx image switch, a proposal): alpine or debian. A site's own image always wins. dqx 1.77+ | alpine |
php | PHP settings the project needs, applied to every site's container | none |
sync | A backend's file sync: mode: auto|off, and directories to exclude | auto |
e2e.directory | A browser suite of the project's own, from the project root; under www/ in a multi project (guide) | the site's checkout |
extras | Keys from an older config dqx does not own, carried through untouched | none |
Per site
| key | what it does | when absent |
|---|---|---|
domains.primary | The domain this site serves | required |
domains.aliases | More domains into the same webroot | none |
domains.wildcardSubdomains | Serve *.primary too | off |
php | PHP version for this site | the image's |
node | Node version | whatever the image has |
image | The container image - docker only. New projects get a dqx-php image; see PHP images | derived from php |
scaffold | Commands that create this site's application instead of cloning it | none |
publicDir | The docroot inside the checkout | public |
directory | The checkout's directory name | the primary domain |
database | This site's schema | client.key |
env | Environment this site's requests, commands and daemons run with, over the checkout's .env | none |
services | Which of the project's services this site uses | all of them |
daemons | Long-running processes: a preset (horizon, queue, schedule, reverb, ssr) or {name, command}, with autostart, image, stopGracePeriod and healthcheck. See daemons | none |
repositories | What is checked out here, and from where | required, unless scaffold is set |
hooks.postInstall | Commands run after every install, for this site | none |
hooks.preCommit | Commands run by the pre-commit hook dqx installs: shorthand for hooks.git.pre-commit | Pint and Prettier |
hooks.git | Any git hook by its name: commands run in the site, or {script: path} run on your machine with git's arguments. See git hooks | none |
install.gitHooks | Write git hooks for this site: false leaves them out, true writes them where the project's install.gitHooks is off | the project's install.gitHooks |
checkoutFiles | false keeps dqx's README block, agent notes (AGENTS.md, CLAUDE.md, .ai/guidelines/) and .mcp.json out of this checkout: for a repository that is not yours, such as a client's under review | true |
secured | Whether this site is served over https | true |
install.npm | Run the node install and build for this site: false skips it here, true runs it where the project's install.npm is off | the project's install.npm |
nginx.cors | open has nginx allow the calling origin with credentials and answer every preflight itself; an object sets origins, credentials, methods, headers, exposeHeaders and maxAge (guide); off leaves CORS to the application. Docker only | off |
nginx.maxBodySize | The largest request body nginx accepts: 64m, 512k, 1g. PHP and proxied sites. Docker only | 64m |
nginx.timeout | Seconds nginx waits on PHP or the dev server before a 504. Docker only | 60 |
nginx.headers | Response headers added to everything the site serves, error pages included. Docker only | none |
nginx.frontController | The PHP file every route that is not a file goes to, from the docroot (guide). Docker, and DDEV's extra docroots | /index.php |
nginx.fastcgiParams | fastcgi_param lines for the site's PHP requests, such as PHP_VALUE. Docker, and DDEV's extra docroots | none |
storage | Directories a snapshot must carry beside the database | none |
configFiles | Files dqx install copies into the checkout where they are missing, each {from, to}: from from the project root, to from the checkout. For a config template kept outside www/ | none |
packages | OS packages the site needs: a name, or {apk, apt, dnf} where the distros differ (more). Docker and DDEV | none |
phpExtensions | PHP extensions installed on top of the image (more). Docker and DDEV | none |
proxy · proxyCommand | A dev server to put in front, and how to start it | none |
proxyBuild | What dqx build runs to write publicDir, served while the dev server is down (more) | none |
e2e.name · e2e.reset · e2e.setup · e2e.commands | What dqx e2e does with this site: the <NAME> in E2E_<NAME>_URL, a reset that replaces migrate:fresh --seed (or false), commands run before the suite, and commands a test may run during it through the relay | first domain label, migrate:fresh --seed, none, none |
Every key
This lists all of them. dqx config get prints what a project resolves to, including the defaults it never wrote down.
A site's nginx
yaml
sites:
- domains: { primary: api.acme.dqx }
nginx:
maxBodySize: 200m # uploads up to 200 MB
timeout: 300 # an import that takes five minutes
headers:
X-Frame-Options: DENYEach header is written into every location dqx generates, with always. nginx only passes a header on to a location that sets none of its own, so one written once at the top would silently miss the PHP responses. Headers are for what the application does not send itself: a header the app sends too arrives twice. For the same reason a CORS header beside cors: open is refused.
nginx and headers has the whole of it: what every site gets, applying a change, and whose file is whose.
php and image
Declare php. It is the portable one: docker is the only backend that has images at all, so on lerd, herd and DDEV php is the only thing there is to read. On docker, php alone resolves through an older table (php: '8.4' gives dolphiq/php84-composer-npm:2), kept so an existing config does not move to another image behind your back.
A new project runs a dqx-php image, named with image: dqx init writes it, and dqx image switch writes it for an existing site. See PHP images.
yaml
php: '8.4'
image: dolphiq/dqx-php84-alpine-slim:1.0 # wins on docker; `php` answers for every other backend
node: '22'image also takes an image of your own, a project that builds one, or a tag of your choice:
yaml
php: '8.4'
image: our-own/app:2When both are set, image wins on docker and php wins everywhere else. That is why a disagreement matters: a site declaring php: '8.4' beside dolphiq/php83-composer-npm:3 runs 8.3 today and 8.4 after dqx switch. dqx doctor reports the pair rather than picking one - which of the two is meant is your answer, not dqx's.
Sites on different versions get a container each, on docker. The first site's version is the app container; a site on another version gets app<version> (app83), a copy of app on its own image, and its nginx, its daemons and dqx exec, composer and php for it run there. That is how a project moves its sites to a new PHP version one at a time. A service the project declares under that name itself is kept as it is.
A php version dqx has no image for is refused by name at dqx sync, never guessed into an image that does not exist.
PHP extensions and packages
yaml
php: '8.4'
phpExtensions: [pcov, imagick]
packages:
- ghostscript # the same name everywhere
- { apk: font-dejavu, apt: fonts-dejavu } # where the distros differOn docker, dqx writes docker/app/<service>.Dockerfile: the site's own image, with the extensions installed by install-php-extensions (which knows what each one needs on every distro) and the packages by whichever of apk, apt-get or dnf the image has. The fleet's images are Alpine from PHP 8.3 and Debian before it, so a name that differs between them needs the map form; a map without a name for the image's manager stops the build and says so. The image is tagged by the Dockerfile's content, so dqx up builds it once, and again only after it changes. A postgres project gets pdo_pgsql, pgsql and postgresql-client without declaring them. On a Debian image without compilers, such as dolphiq/dqx-php*-debian-*, it installs $PHPIZE_DEPS first, since the installer expects them there.
Delete the first line of the Dockerfile to take it over, or declare compose.app.build to use your own. On DDEV the same keys become webimage_extra_packages, with the apt name. dqx doctor checks php -m for every extension a site needs, on every backend.
Repositories
yaml
repositories:
- repository: my-app # resolved against the machine's base
branch: main
- repository: git@github.com:acme/other-app.git # or a full URL, taken as written
path: packages/other
- repository: git@github.com:acme/marketing.git # not everyone has this one
optional: trueA bare name resolves against repositoryBase from ~/.config/dqx/config.yml, whose default is the fleet's own organisation - set it once if that is not yours. A URL is used as it stands. That is what lets one project mix hosts - an application on one, a shared library on another.
optional: true marks code not everyone can read. dqx checks access to every repository before it clones anything; a required one you cannot read stops the install there, while an optional one is reported and skipped and the rest of the project installs.
Names are pinned when dqx writes the config
repositoryBase is a setting on your machine; .dq/config.yml is committed. So a bare name in a committed file depends on state no colleague shares and no history records - and the day a machine default changes, an old project clones from a host its code was never on.
dqx init and dqx adopt therefore write the URL a bare name resolves to, rather than the name. You can also give a full URL at init and it is taken as written:
bash
dqx init --repo git@github.com:acme/app.git=app.dqx
dqx init --repo my-app=app.dqx # written as git@ssh.bitbucket.org:…/my-app.gitA name you type into the config by hand still resolves against repositoryBase - pinning happens when dqx writes the file, not when it reads one.
Local credentials only
The database user and password in this file are local development credentials, and the file is committed. See what dqx stores for what belongs here and what does not.
Packages
yaml
packages:
- repository: acme-core # a bare name or a URL, as for repositories
path: acme/packages/core # under www/, shared by every site
branch: development # the default
composer: acme/core # ties the package to the sites that require it
optional: true # no access skips it instead of stopping the install
gitHooks: false # no git hooks here; absent, it follows install.gitHooks
hooks: # its own git hooks; absent, it takes its site's
git:
pre-push: [composer test]
sites:
- domain: shop.acme.dqx
local:
composer: composer-local.json # or command: a command line; after: one run afterwards
install:
packages: packaged # or localGit repositories developed inside the project and used by its sites as composer packages. dqx clones and access-checks them with the rest, and install.packages (or dqx packages local on one machine) decides whether the sites install the released packages or the local copies. A package path may not overlap a site checkout, a project repository or another package, and packages need a multi layout. Packages inside a project has the whole workflow.
Reading the schema
Every key, its type and its default, out of the schema itself rather than a list somebody maintains:
bash
dqx config schema # all of them
dqx config schema database # one branch
dqx config schema --json # for a program
dqx config get # what this project resolves to, defaults includedThe dqx_config_schema MCP tool returns the same thing, so an agent writing a config works from what dqx currently accepts rather than from what it remembers.
Machine settings
~/.config/dqx/config.yml, read for every command:
yaml
repositoryBase: git@github.com:acme
docsPath: /opt/dqx/site/.vitepress/dist
routerBindAddress: 127.0.0.1
suffixes: [dqx, local.dolphiq.eu]
defaultSuffix: dqx| key | what it does | default |
|---|---|---|
repositoryBase | where a bare repository name resolves to. Set it if you are not at Dolphiq | the fleet's Bitbucket organisation |
docsPath | a built documentation site, for a dqx installed away from its checkout | none |
routerBindAddress | which address the shared router publishes 80 and 443 on - and so the address dqx's resolver answers its suffixes with. dqx loopback sets it | 127.0.0.1, this machine only |
suffixes | the domain suffixes dqx serves here, besides the ones projects state. dqx suffix add / remove | [dqx, local.dolphiq.eu] on a Dolphiq machine, [dqx] elsewhere |
defaultSuffix | what a new project's domains end in; must be one of suffixes. dqx suffix default | dqx |
alsoResolve | suffixes dqx's resolver also answers, for another tool on this machine: { test: 127.0.0.1 } hands .test to Herd | none |
review | your defaults for dqx review: language, codeLanguage, model, effort, budget, profiles and the rest of the review block. The dashboard's ⚙ review settings writes the defaults on its Machine tab | none: nl, code samples in en |
bash
dqx config machine # what is set, and what is a default
dqx config machine routerBindAddress 127.0.0.1 # set oneNaming one address instead of 0.0.0.0 leaves the others free, which is how dqx runs beside a tool whose ports cannot move. Both settings are read by the resolver when it starts, so run dqx resolver install after changing them - dqx loopback and dqx suffix do that themselves. See next to Herd, Valet or Sail.
dqx config machine reads and writes them.
review:
The settings dqx review and the dashboard's review tab read, in a block of the same shape in two files: this machine's ~/.config/dqx/config.yml and the project's .dq/config.yml. The project wins, and a branch rule, a profile, the pull request's last review and the run's own flags come after it.
yaml
review:
language: en # the language findings are written in, any ISO 639-1 code
codeLanguage: match # code samples: a code, or match the codebase's identifiers
instructionsFile: .dq/review.md
targets: # what `dqx review --branch` compares against, first match wins
"hotfix/*": main
"*": development
model: sonnet # an alias or a full model id; default: the claude CLI's own
effort: medium # low | medium | high | xhigh | max; default: the claude CLI's own
budget: 5 # US dollars per review, for API-key users; none by default
mapFlows: on-request # the flow map: on-request (default) or with-review, made by every review
suggestProfile: quick # the profile "Suggest with AI" runs with; default: quick
fixProfile: deep # the profile "Fix automatically" runs with; default: the review's own
fixTest: phpunit # a declared command for the fix's tests; default: test, else one named *test*
ignoreAuthors: [Pipelines] # Fix feedback hides these authors' short general notes; none by default
profiles: # named ways of reviewing, each key optional
security: # the name of a built-in: replaces dqx's own security profile
instructions: .dq/review-security.md # replaces instructionsFile for this profile
model: opus
effort: high
migrations:
instructions: .dq/review-migrations.md
profileFor: # source branch glob → profile, first match wins, first review only
"hotfix/*": quick # a built-in profile needs no entry under profilesThe block is not part of the v4 schema, so it is not in the table below: the project file carries it through untouched, and a bad key, profile or rule is dropped with a warning while the rest apply. profiles merge by name across the two files; targets and profileFor are replaced whole. The profile name none is reserved. mapFlows: with-review has each review's Claude run return the flow map for the human review too; see mapping the flows. ignoreAuthors is matched without regard to case, and hides only short general comments, never an inline one or a longer review; see fixing the feedback. fixTest has to name a command the project declares, as dqx run lists them; the automatic fix does not run tests itself yet.
dqx ships three profiles in a layer below both files, so they work without any config:
| profile | model | effort | instructions |
|---|---|---|---|
quick | sonnet | low | only blockers and majors, kept short; in place of instructionsFile |
thorough | opus | high | none of its own: instructionsFile stays |
security | opus | max | a security focus with the checklist's security section; in place of instructionsFile |
A profile of the same name under profiles, in either file, replaces the built-in whole: its model, effort and instructions. Without instructions it reviews with instructionsFile, not with dqx's text. See built-in profiles.
dqx config machine sets flat settings only: edit the block in the file, or use the dashboard, where ⚙ review settings on a project's pull requests page writes both: the project's block on its This project tab and the machine's defaults on its Machine tab. What each key does, the order of the layers, and the instruction files beside it: reviewing a pull request.
The escape hatch
Anything dqx does not generate, per compose service, merged last:
yaml
compose:
app:
build:
context: .
dockerfile: docker/app/DockerfileDocker-only and deliberately uninterpreted. It merges the way compose merges an override file:
| Key | Over what dqx generated |
|---|---|
environment, labels | merged per variable, yours wins; a map and a KEY=value list mean the same |
volumes | added; one on the same container path replaces dqx's |
command, entrypoint | replaced |
any other list (ports, extra_hosts, ...) | added, without repeating an entry |
any other map (healthcheck, ...) | merged one level deep, yours wins |
| anything else | taken as written |
So environment: { COMPOSER_MEMORY_LIMIT: -1 } adds one variable and keeps dqx's VITE_PORT. A config that overrides one of the keys dqx generates requires dqx 1.66: before it, the override replaced the key whole.
dqx adopt fills it in for a project whose stack has keys dqx would otherwise drop, so adopting an unusual project loses nothing.
The version floor
yaml
requires: '1.1'The oldest dqx that reads this project correctly. You never write it: dqx derives it from the keys you used and checks it on every load.
It exists because an older dqx does not lack a key, it misreads the config. Unknown keys are dropped, so:
| a config using | read by an older dqx |
|---|---|
layout: single | treated as multi, and installs into a directory that does not exist |
compose: overrides | dropped, so the next regenerate removes them |
scaffold: | skipped, so the application is never created |
database.password | dropped, and the stack rebuilt with the wrong password |
| a repository URL | resolved as a bare name, and cloned from an address that does not exist |
gitHooks: false on a site or package | dropped, so the hook the team switched off is written anyway |
hooks.git, a package's hooks | dropped, and lost for good the next time that dqx rewrites the config |
All of these fail silently. That is why the floor is derived and not typed by hand.
A dqx run from a checkout, or built from one, reports the version it was last released as. It reads a project with a higher floor anyway and says so, because that floor is usually the release the branch is about to become. A released dqx still refuses.
The project's install.sh carries a smaller number of its own: the oldest dqx that can run that script. It holds no logic, so it changes almost never. Your project's requirements belong here, and arrive here by themselves.
Several databases, one user
yaml
database:
type: mysql
version: '8.4'
user: om # one user, grants on all of them
schemas: [app_api_staging] # schemas no single site owns
sites:
- domains: { primary: api.app.dqx }
database: app_api # app_api_test comes with it
- domains: { primary: admin.app.dqx }
database: app_admin_portal # so does app_admin_portal_testEvery site schema gets a _test twin, and a wildcard grant on <schema>_test_% so Laravel's parallel testing can make a database per worker without root.
Filling a schema from a dump
Some data predates the application and no migration writes it - a legacy database a test suite reads. schemas names it; imports fills it:
yaml
database:
schemas: [legacy_test]
imports:
- schema: legacy_test
file: api.app.dqx/database/legacy.dump.gz # relative to www/, or to the project when singleThe file ships inside the checkout that owns it, so the repository holding it has to be cloned first
- which it is, because imports run after the clones and after the schemas exist. A
.gzis unpacked on the way in. Re-running an install imports it again, so treat it as "this is what the schema holds", not as a one-off seed.
Every backend, every install. One generator, four ways in: compose exec on docker, ddev mysql -uroot, podman exec on lerd, the host's mysql on herd. The SQL is idempotent, so a schema you add later is created the next time somebody installs.
Why not docker/conf/mysql/init/*.sql?
That file runs once, on an empty data directory, and only on docker. A schema added a year later is never created on a machine that already has data. One project spent an afternoon on Access denied for user for exactly that reason.
dqx still writes the file for docker, generated from these keys, but the provisioning step is what runs.
dqx adopt reads an existing one, at whatever path the stack mounts as /docker-entrypoint-initdb.d, and takes:
- schemas nobody's
.envmentions →database.schemas - the application user →
database.user, fromCREATE USERor from a bare grant - anything matched to a site by resemblance, reported as check this one
- a
GRANT ALL ON *.*, reported rather than reproduced
The password in that file is not read.
Postgres
yaml
database:
type: postgres
version: '17' # the major version; postgres has no 8.4
user: appOn the docker backend only, for now: lerd, DDEV and Herd refuse a postgres project at dqx sync and dqx install rather than build it a MySQL stack. What changes against MySQL:
- the container runs
postgres:<version>on port 5432, checked withpg_isready; its superuser ispostgres, withrootPasswordas its password; - each schema is owned by
user, which also getsCREATEDB: postgres 15 no longer lets anyone create inpublic, and Laravel's parallel testing creates its_test_<n>databases itself; .envgetsDB_CONNECTION=pgsql;- imports, snapshots and restores use
psqlandpg_dumpinside the database container, as the schema's owner, so what they load stays the application's; settingsare the samekey = valuelines, passed to the server as-c key=valueon the db service's command (dqx adoptreads them back from a Sail stack'spostgres -c ...);dqx tuneleaves the database alone.
yaml
database:
type: postgres
version: '17'
settings:
- max_locks_per_transaction = 256The app container gets pdo_pgsql, pgsql and the postgres client (psql, for dqx doctor's schema check) by itself on docker: the fleet's dolphiq/php*-composer-npm images ship none of them, so dqx builds the site's image with them on top (see PHP extensions and packages). Nothing to declare.
Another image of the same engine
yaml
database:
type: postgres
version: '17' # the engine inside the image
image: postgis/postgis:17-3.5
extensions: [postgis]image replaces only the image: type still decides the client, the variables, the port and the healthcheck, so it works for images that keep the official image's entrypoint - PostGIS, pgvector, TimescaleDB, a MySQL with plugins or a mirror of the official one. It needs a tag or a digest, never latest. dqx doctor and the dashboard say so when its tag names another engine version than version, or when the running stack is on another image.
extensions are created by dqx as the superuser, in every schema and in template1 (which Laravel's parallel testing copies). That is the point of the key: postgis is not a trusted extension, so the application's user cannot create it, and a migration's own CREATE EXTENSION IF NOT EXISTS postgis only succeeds because it is already there. Postgres only.
dqx adopt reads a PostGIS, pgvector or TimescaleDB stack as postgres, with the engine version from the tag and the image beside it.
Server settings
yaml
database:
settings:
- innodb_buffer_pool_size = 1G
- max_allowed_packet = 256MWritten into docker/conf/mysql/mysql_extra.cnf on top of the defaults every project here runs, and stated once - a project overriding sql_mode replaces that line rather than adding a second. dqx adopt reads an existing file into these keys.
The one default worth knowing
performance_schema is off. Measured on mysql:8.0, idle, with a fresh data directory: 361 MB with it, 132 MB without - 229 MB per database container, on a machine that runs several projects at once. What it costs you is the sys schema, which reads it, and the query profiling built on that.
A project restoring a staging dump wants both of those back, and more room to work in:
yaml
database:
settings:
- performance_schema = ON
- innodb_buffer_pool_size = 4G
- innodb_redo_log_capacity = 512M
- max_allowed_packet = 1GA declared setting replaces the default rather than being added beside it, so this is the whole mechanism - there is no second file and no flag. innodb_buffer_pool_size is committed lazily: the container's memory grows as the restore reads pages in, and an idle server with a 4 GB pool declared does not hold 4 GB. Set it back when the work is done, or leave it and pay for it while that project runs.
Docker only, today
The other three backends run their own database server and do not read this yet. On DDEV, put a .cnf in .ddev/mysql/; herd and lerd use the server on your machine. The keys are backend-neutral on purpose (D39) - applying them everywhere is work that has not been done.
Credentials
yaml
database:
user: om
password: … # optional
rootPassword: … # optionalLeave both out. Absent means the value every project here runs. Measured: 31 of 31 stacks carry identical credentials, so declaring them writes a password into a file for nothing.
One value, because three things have to agree:
| the compose environment | MySQL's entrypoint creates MYSQL_USER with it, before any init script runs |
| the generated init SQL | ALTER USER, which corrects what CREATE USER IF NOT EXISTS cannot |
each site's .env | what the application connects with |
When they disagree you get Access denied for user 'db' against a schema and a user that both exist. One project lost an afternoon to it.
Declare them when a data directory forces you to. MySQL sets the root password only when it initialises an empty directory. Inherit a project whose data was made with another one and the healthcheck (mysqladmin -uroot -p…) can never pass: up --wait hangs and says nothing. A declared rootPassword reaches the environment, the init SQL and the probe together.
dqx adopt writes these keys only when the stack differs from the fleet, so adopting 31 identical projects adds a password to none of them.
No rootUser: MySQL's superuser is always root. What varies is MYSQL_ROOT_HOST, already %.
A local development credential, never a real one
.dq/config.yml is in git, in every clone, in every backup. A staging or production password here is published to all three. Those belong in the password manager (ISMS §8).
Every key, from the schema
Read out of the schema that validates your config, so it cannot fall behind it. The sections above say what the important ones are for; this says what exists.
A key with a badge needs at least that release. Eleven of them do, and the badge comes from the same table the version floor is derived from, so a key cannot gain a floor without gaining its badge.
The what it is column is the schema's own description, which is what dqx config schema prints and what the dqx_config_schema MCP tool answers with. One source, so a key cannot mean one thing here and another in a terminal.
| key | type | default | what it is |
|---|---|---|---|
version | 4 | none | The config shape. dqx reads 1 to 4 and writes 4 |
domainSuffix | string | "local.dolphiq.eu" | The suffix every domain here sits under, declared because it cannot be derived |
runtime | string | null | Pin this project to one backend. Null takes whatever the machine has chosen |
layout dqx 1.0+ | single · multi | "multi" | Where the code sits: single is one application repository that is the project, multi a wrapper with a checkout per site under www/ |
compose dqx 1.1+ | map | {} | Compose keys per service, merged last the way compose merges an override file: environment and labels per variable, volumes by container path, command replaced. Docker only |
ports dqx 1.45+ | map | {} | Host ports this project wants by number, keyed as dqx info names them. Anything unnamed still gets a stable port of its own |
requires | string | null | The oldest dqx that reads this config correctly. Written by dqx, not by hand |
client | object | none | Who this project belongs to |
client.key | string | none | The short name prefixing every container, network and schema |
client.name | string | none | The project's name, as a person would write it |
clone | object | null | Set only in a copy made by dqx clone or dqx agent-env |
clone.of | string | none | The absolute path of the project this is a copy of |
clone.segment | string | none | The label telling this copy apart, in its client key and in every domain |
clone.temporary | boolean | false | Whether dqx clone --prune may remove this copy |
clone.createdAt | string | none | When the copy was made, as an ISO timestamp |
clone.createdBy | human · agent | "human" | Who asked for this copy. An agent may only ever remove one an agent made |
clone.task | string | null | What this copy was made for, shown on its card |
clone.worktrees | list of string | [] | Checkouts of this copy that are git worktrees of the original's, not clones |
clone.branch | string | null | The branch those worktrees are on |
clone.branchCreated | boolean | false | Whether dqx started that branch, and so may remove it with the copy |
database | object | null | The database server this project runs, and the schemas no single site owns |
database.type | string | "mysql" | The database server: mysql, mariadb, percona or postgres |
database.version | string | "8.0" | The server version, which pins the image tag |
database.user | string | "" | The application's database user. Empty means nobody wrote one down |
database.schemas | list of string | [] | Schemas created on every install, beyond the one each site owns |
database.password dqx 1.1+ | string | null | A local development password for the application user, where it must differ |
database.rootPassword dqx 1.1+ | string | null | A local development password for the server's own root, where it must differ |
database.settings | list of string | [] | Server settings this project needs: sql_mode, innodb_buffer_pool_size |
database.imports dqx 1.6+ | list of object | [] | Dumps loaded at install time, for data no migration can produce |
database.imports[] | object | none | One dump, and the schema it is loaded into |
database.imports[].schema | string | none | The schema this dump is loaded into |
database.imports[].file | string | none | The dump, relative to the checkout root. A .gz is unpacked on the way in |
database.image | string | none | The database image, where it is not the official one: postgis/postgis:17-3.5 |
database.extensions | list of string | none | Postgres extensions dqx creates in every schema, as the superuser: postgis |
repositories | list of object | [] | Code the project needs that belongs to no site, cloned relative to www/ |
repositories[] | object | none | One repository cloned into a checkout |
repositories[].repository dqx 1.1+ | string | none | The repository to clone: a bare name expanded through the fleet's host, or a URL |
repositories[].branch | string | "development" | The branch checked out after cloning |
repositories[].path | string | "" | Where under the checkout this repository is cloned, when it is not the checkout |
repositories[].optional dqx 1.6+ | boolean | false | Skip this repository when it cannot be reached, rather than failing the install |
packages dqx 1.60+ | list of object | [] | Packages developed inside this project, cloned under www/ |
packages[] | object | none | A package developed inside this project |
packages[].repository | string | none | The repository to clone: a bare name expanded through the fleet's host, or a URL |
packages[].path | string | none | Where under www/ the package is cloned |
packages[].branch | string | "development" | The branch checked out after cloning |
packages[].composer | string | null | Its composer name, which ties it to the sites that require it |
packages[].npm | string | null | Its npm name, shown beside it |
packages[].optional | boolean | false | Skip this package when it cannot be reached, rather than failing the install |
packages[].gitHooks dqx 1.61+ | boolean | null | Write git's pre-commit hook in this package: false leaves it out, null follows install.gitHooks |
packages[].hooks dqx 1.61+ | object | {"preCommit":[],"git":{}} | This package's own git hooks; none stated, it takes those of the site it runs through |
packages[].hooks.preCommit | list of string | [] | Commands git runs before a commit in this checkout, through dqx exec: shorthand for git.pre-commit |
packages[].hooks.git | map | {} | Any git hook by its own name, as commands run in the site or {script: path} run on this machine |
scaffold dqx 1.1+ | string | null | The recipe this project was created from, resolved to its version |
notes | list of string | [] | Lines printed after a successful install: what a person still has to do by hand |
hooks | object | {} | Commands the project runs, in www/, rather than in any one site |
hooks.preCommit | list of string | [] | Commands git runs before a commit in this checkout, through dqx exec: shorthand for git.pre-commit |
hooks.git | map | {} | Any git hook by its own name, as commands run in the site or {script: path} run on this machine |
hooks.postInstall | list of string | [] | Commands run in the site once it is installed: key:generate, migrate, npm run build. A site's own get its env; the project's read the checkout's .env |
sites | list of object | none | What this project serves: one domain and one docroot each |
sites[] | object | none | One site: a domain, a checkout and the docroot within it |
sites[].domains | object | none | The domains this site answers on |
sites[].domains.primary | string | none | The domain this site is served on, and the name its certificate is issued for |
sites[].domains.wildcardSubdomains | off · preferred · required | "off" | Whether every subdomain of the primary domain reaches this site too |
sites[].domains.aliases | list of string | [] | Further whole domains serving this same site, routed and certified with it |
sites[].proxy dqx 1.64+ | number | 0 | Proxy this site to a dev server on this port instead of serving files. 0 is off |
sites[].proxyCommand | string | "npm run dev" | What starts that dev server, run in the checkout |
sites[].proxyBuild | string | null | What builds this dev-server site into publicDir, run by dqx build only. Choose one that targets the local site: a plain vite build uses .env.production |
sites[].directory | string | "" | The checkout directory under www/ this site is served from, when it is not named after the primary domain. NOT a subfolder inside the repository: the repository is cloned into this directory, and publicDir is the docroot within it |
sites[].secured | boolean | true | Serve this site over HTTPS. lerd and herd follow it; behind the docker router every site is HTTPS |
sites[].checkoutFiles | boolean | none | Let dqx write its README block, agent notes and .mcp.json into this site's checkout. false for a repository that is not yours |
sites[].php | string | null | The PHP version this site runs, where it differs from the project's |
sites[].node | string | null | The node version this site builds with, where it differs from the project's |
sites[].publicDir | string | "public" | The docroot within the checkout, which is what nginx serves |
sites[].image | string | null | The container image this site's application runs on. Docker only |
sites[].database | string | null | The schema this site owns. Sites share a server, not a schema |
sites[].env dqx 1.47+ | map | {} | Environment this site's own requests, commands and daemons run with, overriding the checkout's .env. For a site that shares another site's checkout and needs values of its own. NOT the place for the project's ordinary configuration: that stays in .env |
sites[].configFiles dqx 1.74+ | list of object | [] | Files dqx install copies into the checkout when they are not there yet, such as a config template kept outside www/ |
sites[].configFiles[] | object | none | One file, copied once and never overwritten |
sites[].storage | list of string | [] | Paths holding data nobody can regenerate, which dqx snapshot carries |
sites[].phpExtensions | list of string | [] | PHP extensions the image does not ship, installed on top of it |
sites[].packages | list of string or object | [] | OS packages this site needs: a name, or {apk, apt, dnf} where the distros name it differently |
sites[].daemons | list of horizon · queue · schedule · reverb · ssr or object | [] | Long-running processes this site needs, such as a queue worker or a scheduler |
sites[].services | list of string | null | Which of the project's services this site uses. Null means all of them |
sites[].repositories | list of object | [] | The code cloned into this site's checkout. None where it declares a scaffold |
sites[].repositories[] | object | none | One repository cloned into a checkout |
sites[].scaffold dqx 1.6+ | object | null | An application this site creates rather than clones |
sites[].scaffold.create | list of string | [] | Commands that create the application, run before there is an .env |
sites[].scaffold.setup | list of string | [] | Commands run after the .env exists and the database is reachable |
sites[].hooks | object | {} | Commands this site runs: before a commit, and after install |
sites[].hooks.preCommit | list of string | [] | Commands git runs before a commit in this checkout, through dqx exec: shorthand for git.pre-commit |
sites[].hooks.git dqx 1.61+ | map | {} | Any git hook by its own name, as commands run in the site or {script: path} run on this machine |
sites[].hooks.postInstall | list of string | [] | Commands run in the site once it is installed: key:generate, migrate, npm run build. A site's own get its env; the project's read the checkout's .env |
sites[].install | object | {"npm":null,"gitHooks":null} | What this site installs where it differs from the project's install |
sites[].install.npm | boolean | null | Run the node install and build in this site: false skips it here, null follows install.npm |
sites[].install.gitHooks dqx 1.61+ | boolean | null | Write git's pre-commit hook in this site: false leaves it out, null follows install.gitHooks |
sites[].local dqx 1.60+ | object | null | How this site installs against the local packages; absent: always packaged |
sites[].local.composer | string | null | The composer file local mode installs with |
sites[].local.command | string | null | A command line that switches the site to the local packages |
sites[].local.after | string | null | A command line run after switching to local, such as an npm install |
sites[].nginx | object | {"cors":"off","maxBodySize":"64m","timeout":60,"headers":{},"frontController":"/index.php","fastcgiParams":{}} | What this site's generated nginx server block adds beyond serving it. Docker only |
sites[].nginx.cors dqx 1.64+ | off · open or object | "off" | off leaves CORS to the application. open has nginx allow the calling origin with credentials, every method and the requested headers, and answer every preflight itself, which doubles the headers of an app that sends its own. An object sets origins, credentials, methods, headers, exposeHeaders and maxAge itself |
sites[].nginx.cors.origins | reflect · * or list of string | "reflect" | reflect answers whichever origin calls, * any origin without credentials, or a list of the origins allowed |
sites[].nginx.cors.credentials | boolean | true | Let the browser send cookies: Access-Control-Allow-Credentials: true |
sites[].nginx.cors.methods | list of string | ["GET","POST","PUT","PATCH","DELETE","OPTIONS"] | The methods a preflight may ask for |
sites[].nginx.cors.headers | "reflect" or list of string | "reflect" | reflect allows the request headers a preflight asks for, or a list of them |
sites[].nginx.cors.exposeHeaders | list of string | [] | Response headers the page's script may read: Access-Control-Expose-Headers |
sites[].nginx.cors.maxAge | number | null | Seconds a browser may cache a preflight answer; null sends no Access-Control-Max-Age |
sites[].nginx.maxBodySize | string | "64m" | The largest request body nginx accepts, as client_max_body_size: an upload limit |
sites[].nginx.timeout | number | 60 | Seconds nginx waits on PHP or on the dev server before it answers 504 |
sites[].nginx.headers | map | {} | Response headers nginx adds to everything this site serves, error pages included. For what the application does not send itself |
sites[].nginx.frontController dqx 1.73+ | string | "/index.php" | The PHP file every route that is not a file goes to, from the docroot |
sites[].nginx.fastcgiParams dqx 1.73+ | map | {} | fastcgi_param lines for this site's PHP requests, such as PHP_VALUE; for values a site's environment does not carry |
sites[].e2e dqx 1.67+ | object | {"setup":[],"commands":{}} | What dqx e2e does with this site: its reset, its setup, its URL variable's name |
sites[].e2e.name | string | none | The <NAME> in E2E_<NAME>_URL; default the domain's first label |
sites[].e2e.reset | string or false | none | Replaces migrate:fresh --seed before a suite; false skips it |
sites[].e2e.setup | list of string | [] | Commands run in the app container, in this site, before the suite |
sites[].e2e.commands dqx 1.68+ | map | {} | Commands the suite may run in this site during a run, by name, through the relay |
sync | object | {"mode":"auto","exclude":[]} | What this project decides about its backend's file sync |
sync.mode | auto · off | "auto" | Use the backend's file sync where it has one. off is a plain bind mount. DDEV only |
sync.exclude | list of string | [] | Directories kept out of that sync and bind-mounted instead |
e2e | object | {"directory":null} | Where dqx e2e runs a suite of the project's own, rather than in one site |
e2e.directory dqx 1.67+ | string | null | Where the suite lives, from the project root; outside www/ in a multi project dqx mounts it under /var/www by its last folder name |
php | list of string | [] | php.ini settings this project needs: memory_limit, upload_max_filesize |
distro dqx 1.77+ | alpine · debian | none | The distro dqx picks for a dqx-php image it chooses: a new site, dqx image switch, a proposal |
services | list of object | [] | Services this project runs beside its application: redis, meilisearch, mailpit |
services[] | object | none | One service the project runs beside its application |
services[].name | string | none | What this service is called, in the stack and on the card |
services[].image dqx 1.47+ | string | "" | The image it runs, tag included. Only the playwright service may leave it out: its tag comes from the @playwright/test the checkout has installed |
services[].environment | map | {} | Environment variables for this service, such as a search engine's heap cap |
services[].postStart dqx 1.6+ | list of string | [] | Commands run against this service once it is up, to configure it |
services[].command dqx 1.47+ | string | "" | What this service runs, where the image's own command is not what is wanted |
services[].reachesSites dqx 1.47+ | boolean | false | Join the shared router's network, so this service can open the project's own https domains. For a browser container, not for a database |
services[].trustLocalCa dqx 1.47+ | boolean | false | Install this machine's local certificate authority in the container, so it trusts the project's own https domains |
install | object | none | Which steps dqx install runs for this project |
install.gitHooks | boolean | false | Write git's own pre-commit hook into each checkout that has none |
install.env | boolean | false | Create each site's .env from its example |
install.composer | boolean | false | Run composer install in each site |
install.npm | boolean | false | Run the node install and build in each site |
install.ssl | boolean | false | Issue this project's certificate |
install.flux | boolean | false | Authenticate Laravel Flux, which needs credentials from the password manager |
install.packages dqx 1.60+ | packaged · local | "packaged" | Whether install sets the sites up against the packages from the registry or the local ones |
extras | map | {} | Blocks dqx does not model and must not destroy, such as commands: |
Commands the project runs
hooks.postInstall belongs to the project and runs on every install, on every machine. What a colleague needs after composer and npm:
yaml
hooks:
postInstall:
- php artisan migrate --seed
- npm run buildA single site can have its own in sites[].hooks.postInstall. Those run with the site's env, so an E2E site on the same checkout migrates its own database rather than the one .env names. A command that runs the test suite (php artisan test, vendor/bin/pest) is left out of that, so phpunit.xml keeps choosing its database. The project's own hooks belong to no site and read the checkout's .env.
services[].postStart configures a service once it is running - a call the image cannot carry and a healthcheck cannot make:
yaml
services:
- name: elasticsearch
image: elasticsearch:8.17.0
postStart:
- >-
curl -s -X PUT http://my_app_elasticsearch:9200/_cluster/settings
-H 'Content-Type: application/json'
-d '{"persistent":{"cluster.routing.allocation.disk.watermark.low":"97%"}}'These run after dqx up and before anything that uses the service, in the application's container - which has curl, and reaches the service by its compose name.
services[].command, services[].reachesSites and services[].trustLocalCa are what a browser container needs, and nothing else here has asked for yet:
yaml
services:
- name: playwright
image: mcr.microsoft.com/playwright:v1.56.0-noble
command: sleep infinity # its own entrypoint ends, and an exited container runs nothing
reachesSites: true # join the router's network, where this project's domains resolve
trustLocalCa: true # and trust the certificate the router serves for themreachesSites is for a service that opens the project the way a person does, by https domain. Those domains are the shared router's aliases, so a container on the project's own network cannot resolve them at all. trustLocalCa installs this machine's mkcert root in the container: mkcert puts it in the system store, which your browser reads and no container has.
sites[].scaffold is for a site whose application dqx creates rather than clones. A scratch application built to prove your own packages install into a clean project has no repository to check out:
yaml
sites:
- domains: { primary: runner.app.dqx }
scaffold:
create: # before .env exists
- composer create-project laravel/laravel:v12 .
setup: # after .env, database reachable
- php artisan migrate
- npm run buildA site with a scaffold needs no repositories. Unlike a project scaffold, which dqx init runs once, this is written into the config, so every later install builds the same application again.
A scaffold's commands belong to the recipe and run once, when dqx init creates the project. See Scaffolds.
yaml
create: # before .env exists, so does the application
- composer create-project laravel/laravel:^13 .
setup: # after .env, database reachable
- php artisan key:generate
postInstall: # written into .dq/config.yml, so every install runs it
- php artisan migrate
notes: # printed to the person, not run
- 'Create a user: see https://docs.qore.works/packages/qore-cms/#creating-user'A recipe's postInstall is not a fourth phase. It is how the recipe writes the project's hooks.postInstall, so what its author ran once, everybody runs.
What runs when
dqx install --dry-run prints this for your project. The order is fixed:
| clone · register · generate · up | the environment is serving |
| schemas | databases and grants |
create | scaffold only, before there is a .env |
| env | .env written from the config |
setup | scaffold only, database reachable |
| certificate | if install.ssl |
| env · composer · npm · git hooks | per site |
hooks.postInstall | per site, then the project's own |
notes | printed, last |
There is no preInstall: everything here runs inside the site's container, and before up there is no container. Such a hook could only run on your own machine, which is a different thing to trust.
There is no postSetup either. The project-level hooks.postInstall already runs last.
Notes to the person installing
yaml
notes:
- 'Ask a colleague for the API token, it is in the team password manager under "My App staging".'
- 'You need the VPN for the media server.'Printed after a successful dqx install, under Still to do by hand, and after the summary: a line above forty lines of output is a line nobody read.
Use it for what no command can do - an account to create, a credential to fetch, a network to be on. Anything a command can do belongs in hooks.postInstall. A recipe's notes say this once, when the project is created; these say it to everybody who installs it afterwards.