Skip to content

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

Project level ​

keywhat it doeswhen absent
versionThe config format. 4 is currentrequired
client.keyPrefixes container names and the default schemarequired
client.nameHuman-readable, used in generated documentsrequired
domainSuffixThe suffix this project's domains sit underlocal.dolphiq.eu
cloneWritten by dqx clone into a copy: which project it copies, its segment, whether it is temporary, who made it and what fornot a copy
layoutsingle or multi - where the code sitsmulti
runtimePin a backend for this projectthe machine's choice
servicesWhich 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 keynone
databaseType and versionderived from the stack
installWhich install steps runeverything but flux
scaffoldThe recipe this project was created fromnone
repositoriesCode the project needs that belongs to no sitenone
hooks.postInstallCommands run after every install, project-widenone
composeCompose keys per service, merged last over what dqx generatesnone
portsHost ports this project wants by number, keyed as dqx info names themderived per project
requiresThe oldest dqx that reads this correctly - written by dqxderived
distroThe 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
phpPHP settings the project needs, applied to every site's containernone
syncA backend's file sync: mode: auto|off, and directories to excludeauto
e2e.directoryA browser suite of the project's own, from the project root; under www/ in a multi project (guide)the site's checkout
extrasKeys from an older config dqx does not own, carried through untouchednone

Per site ​

keywhat it doeswhen absent
domains.primaryThe domain this site servesrequired
domains.aliasesMore domains into the same webrootnone
domains.wildcardSubdomainsServe *.primary toooff
phpPHP version for this sitethe image's
nodeNode versionwhatever the image has
imageThe container image - docker only. New projects get a dqx-php image; see PHP imagesderived from php
scaffoldCommands that create this site's application instead of cloning itnone
publicDirThe docroot inside the checkoutpublic
directoryThe checkout's directory namethe primary domain
databaseThis site's schemaclient.key
envEnvironment this site's requests, commands and daemons run with, over the checkout's .envnone
servicesWhich of the project's services this site usesall of them
daemonsLong-running processes: a preset (horizon, queue, schedule, reverb, ssr) or {name, command}, with autostart, image, stopGracePeriod and healthcheck. See daemonsnone
repositoriesWhat is checked out here, and from whererequired, unless scaffold is set
hooks.postInstallCommands run after every install, for this sitenone
hooks.preCommitCommands run by the pre-commit hook dqx installs: shorthand for hooks.git.pre-commitPint and Prettier
hooks.gitAny git hook by its name: commands run in the site, or {script: path} run on your machine with git's arguments. See git hooksnone
install.gitHooksWrite git hooks for this site: false leaves them out, true writes them where the project's install.gitHooks is offthe project's install.gitHooks
checkoutFilesfalse 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 reviewtrue
securedWhether this site is served over httpstrue
install.npmRun the node install and build for this site: false skips it here, true runs it where the project's install.npm is offthe project's install.npm
nginx.corsopen 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 onlyoff
nginx.maxBodySizeThe largest request body nginx accepts: 64m, 512k, 1g. PHP and proxied sites. Docker only64m
nginx.timeoutSeconds nginx waits on PHP or the dev server before a 504. Docker only60
nginx.headersResponse headers added to everything the site serves, error pages included. Docker onlynone
nginx.frontControllerThe PHP file every route that is not a file goes to, from the docroot (guide). Docker, and DDEV's extra docroots/index.php
nginx.fastcgiParamsfastcgi_param lines for the site's PHP requests, such as PHP_VALUE. Docker, and DDEV's extra docrootsnone
storageDirectories a snapshot must carry beside the databasenone
configFilesFiles 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
packagesOS packages the site needs: a name, or {apk, apt, dnf} where the distros differ (more). Docker and DDEVnone
phpExtensionsPHP extensions installed on top of the image (more). Docker and DDEVnone
proxy · proxyCommandA dev server to put in front, and how to start itnone
proxyBuildWhat dqx build runs to write publicDir, served while the dev server is down (more)none
e2e.name · e2e.reset · e2e.setup · e2e.commandsWhat 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 relayfirst 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: DENY

Each 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:2

When 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 differ

On 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: true

A 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.git

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

Git 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 included

The 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
keywhat it doesdefault
repositoryBasewhere a bare repository name resolves to. Set it if you are not at Dolphiqthe fleet's Bitbucket organisation
docsPatha built documentation site, for a dqx installed away from its checkoutnone
routerBindAddresswhich address the shared router publishes 80 and 443 on - and so the address dqx's resolver answers its suffixes with. dqx loopback sets it127.0.0.1, this machine only
suffixesthe domain suffixes dqx serves here, besides the ones projects state. dqx suffix add / remove[dqx, local.dolphiq.eu] on a Dolphiq machine, [dqx] elsewhere
defaultSuffixwhat a new project's domains end in; must be one of suffixes. dqx suffix defaultdqx
alsoResolvesuffixes dqx's resolver also answers, for another tool on this machine: { test: 127.0.0.1 } hands .test to Herdnone
reviewyour 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 tabnone: 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 one

Naming 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 profiles

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

profilemodeleffortinstructions
quicksonnetlowonly blockers and majors, kept short; in place of instructionsFile
thoroughopushighnone of its own: instructionsFile stays
securityopusmaxa 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/Dockerfile

Docker-only and deliberately uninterpreted. It merges the way compose merges an override file:

KeyOver what dqx generated
environment, labelsmerged per variable, yours wins; a map and a KEY=value list mean the same
volumesadded; one on the same container path replaces dqx's
command, entrypointreplaced
any other list (ports, extra_hosts, ...)added, without repeating an entry
any other map (healthcheck, ...)merged one level deep, yours wins
anything elsetaken 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 usingread by an older dqx
layout: singletreated as multi, and installs into a directory that does not exist
compose: overridesdropped, so the next regenerate removes them
scaffold:skipped, so the application is never created
database.passworddropped, and the stack rebuilt with the wrong password
a repository URLresolved as a bare name, and cloned from an address that does not exist
gitHooks: false on a site or packagedropped, so the hook the team switched off is written anyway
hooks.git, a package's hooksdropped, 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_test

Every 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 single

The 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 .gz is 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 .env mentions → database.schemas
  • the application user → database.user, from CREATE USER or 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: app

On 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 with pg_isready; its superuser is postgres, with rootPassword as its password;
  • each schema is owned by user, which also gets CREATEDB: postgres 15 no longer lets anyone create in public, and Laravel's parallel testing creates its _test_<n> databases itself;
  • .env gets DB_CONNECTION=pgsql;
  • imports, snapshots and restores use psql and pg_dump inside the database container, as the schema's owner, so what they load stays the application's;
  • settings are the same key = value lines, passed to the server as -c key=value on the db service's command (dqx adopt reads them back from a Sail stack's postgres -c ...); dqx tune leaves the database alone.
yaml
database:
  type: postgres
  version: '17'
  settings:
    - max_locks_per_transaction = 256

The 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 = 256M

Written 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 = 1G

A 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: …        # optional

Leave 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 environmentMySQL's entrypoint creates MYSQL_USER with it, before any init script runs
the generated init SQLALTER USER, which corrects what CREATE USER IF NOT EXISTS cannot
each site's .envwhat 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.

keytypedefaultwhat it is
version4noneThe config shape. dqx reads 1 to 4 and writes 4
domainSuffixstring"local.dolphiq.eu"The suffix every domain here sits under, declared because it cannot be derived
runtimestringnullPin 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
requiresstringnullThe oldest dqx that reads this config correctly. Written by dqx, not by hand
clientobjectnoneWho this project belongs to
client.keystringnoneThe short name prefixing every container, network and schema
client.namestringnoneThe project's name, as a person would write it
cloneobjectnullSet only in a copy made by dqx clone or dqx agent-env
clone.ofstringnoneThe absolute path of the project this is a copy of
clone.segmentstringnoneThe label telling this copy apart, in its client key and in every domain
clone.temporarybooleanfalseWhether dqx clone --prune may remove this copy
clone.createdAtstringnoneWhen the copy was made, as an ISO timestamp
clone.createdByhuman · agent"human"Who asked for this copy. An agent may only ever remove one an agent made
clone.taskstringnullWhat this copy was made for, shown on its card
clone.worktreeslist of string[]Checkouts of this copy that are git worktrees of the original's, not clones
clone.branchstringnullThe branch those worktrees are on
clone.branchCreatedbooleanfalseWhether dqx started that branch, and so may remove it with the copy
databaseobjectnullThe database server this project runs, and the schemas no single site owns
database.typestring"mysql"The database server: mysql, mariadb, percona or postgres
database.versionstring"8.0"The server version, which pins the image tag
database.userstring""The application's database user. Empty means nobody wrote one down
database.schemaslist of string[]Schemas created on every install, beyond the one each site owns
database.password dqx 1.1+stringnullA local development password for the application user, where it must differ
database.rootPassword dqx 1.1+stringnullA local development password for the server's own root, where it must differ
database.settingslist 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[]objectnoneOne dump, and the schema it is loaded into
database.imports[].schemastringnoneThe schema this dump is loaded into
database.imports[].filestringnoneThe dump, relative to the checkout root. A .gz is unpacked on the way in
database.imagestringnoneThe database image, where it is not the official one: postgis/postgis:17-3.5
database.extensionslist of stringnonePostgres extensions dqx creates in every schema, as the superuser: postgis
repositorieslist of object[]Code the project needs that belongs to no site, cloned relative to www/
repositories[]objectnoneOne repository cloned into a checkout
repositories[].repository dqx 1.1+stringnoneThe repository to clone: a bare name expanded through the fleet's host, or a URL
repositories[].branchstring"development"The branch checked out after cloning
repositories[].pathstring""Where under the checkout this repository is cloned, when it is not the checkout
repositories[].optional dqx 1.6+booleanfalseSkip 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[]objectnoneA package developed inside this project
packages[].repositorystringnoneThe repository to clone: a bare name expanded through the fleet's host, or a URL
packages[].pathstringnoneWhere under www/ the package is cloned
packages[].branchstring"development"The branch checked out after cloning
packages[].composerstringnullIts composer name, which ties it to the sites that require it
packages[].npmstringnullIts npm name, shown beside it
packages[].optionalbooleanfalseSkip this package when it cannot be reached, rather than failing the install
packages[].gitHooks dqx 1.61+booleannullWrite 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.preCommitlist of string[]Commands git runs before a commit in this checkout, through dqx exec: shorthand for git.pre-commit
packages[].hooks.gitmap{}Any git hook by its own name, as commands run in the site or {script: path} run on this machine
scaffold dqx 1.1+stringnullThe recipe this project was created from, resolved to its version
noteslist of string[]Lines printed after a successful install: what a person still has to do by hand
hooksobject{}Commands the project runs, in www/, rather than in any one site
hooks.preCommitlist of string[]Commands git runs before a commit in this checkout, through dqx exec: shorthand for git.pre-commit
hooks.gitmap{}Any git hook by its own name, as commands run in the site or {script: path} run on this machine
hooks.postInstalllist 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
siteslist of objectnoneWhat this project serves: one domain and one docroot each
sites[]objectnoneOne site: a domain, a checkout and the docroot within it
sites[].domainsobjectnoneThe domains this site answers on
sites[].domains.primarystringnoneThe domain this site is served on, and the name its certificate is issued for
sites[].domains.wildcardSubdomainsoff · preferred · required"off"Whether every subdomain of the primary domain reaches this site too
sites[].domains.aliaseslist of string[]Further whole domains serving this same site, routed and certified with it
sites[].proxy dqx 1.64+number0Proxy this site to a dev server on this port instead of serving files. 0 is off
sites[].proxyCommandstring"npm run dev"What starts that dev server, run in the checkout
sites[].proxyBuildstringnullWhat 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[].directorystring""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[].securedbooleantrueServe this site over HTTPS. lerd and herd follow it; behind the docker router every site is HTTPS
sites[].checkoutFilesbooleannoneLet dqx write its README block, agent notes and .mcp.json into this site's checkout. false for a repository that is not yours
sites[].phpstringnullThe PHP version this site runs, where it differs from the project's
sites[].nodestringnullThe node version this site builds with, where it differs from the project's
sites[].publicDirstring"public"The docroot within the checkout, which is what nginx serves
sites[].imagestringnullThe container image this site's application runs on. Docker only
sites[].databasestringnullThe 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[]objectnoneOne file, copied once and never overwritten
sites[].storagelist of string[]Paths holding data nobody can regenerate, which dqx snapshot carries
sites[].phpExtensionslist of string[]PHP extensions the image does not ship, installed on top of it
sites[].packageslist of string or object[]OS packages this site needs: a name, or {apk, apt, dnf} where the distros name it differently
sites[].daemonslist of horizon · queue · schedule · reverb · ssr or object[]Long-running processes this site needs, such as a queue worker or a scheduler
sites[].serviceslist of stringnullWhich of the project's services this site uses. Null means all of them
sites[].repositorieslist of object[]The code cloned into this site's checkout. None where it declares a scaffold
sites[].repositories[]objectnoneOne repository cloned into a checkout
sites[].scaffold dqx 1.6+objectnullAn application this site creates rather than clones
sites[].scaffold.createlist of string[]Commands that create the application, run before there is an .env
sites[].scaffold.setuplist of string[]Commands run after the .env exists and the database is reachable
sites[].hooksobject{}Commands this site runs: before a commit, and after install
sites[].hooks.preCommitlist 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.postInstalllist 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[].installobject{"npm":null,"gitHooks":null}What this site installs where it differs from the project's install
sites[].install.npmbooleannullRun the node install and build in this site: false skips it here, null follows install.npm
sites[].install.gitHooks dqx 1.61+booleannullWrite git's pre-commit hook in this site: false leaves it out, null follows install.gitHooks
sites[].local dqx 1.60+objectnullHow this site installs against the local packages; absent: always packaged
sites[].local.composerstringnullThe composer file local mode installs with
sites[].local.commandstringnullA command line that switches the site to the local packages
sites[].local.afterstringnullA command line run after switching to local, such as an npm install
sites[].nginxobject{"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.originsreflect · * or list of string"reflect"reflect answers whichever origin calls, * any origin without credentials, or a list of the origins allowed
sites[].nginx.cors.credentialsbooleantrueLet the browser send cookies: Access-Control-Allow-Credentials: true
sites[].nginx.cors.methodslist 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.exposeHeaderslist of string[]Response headers the page's script may read: Access-Control-Expose-Headers
sites[].nginx.cors.maxAgenumbernullSeconds a browser may cache a preflight answer; null sends no Access-Control-Max-Age
sites[].nginx.maxBodySizestring"64m"The largest request body nginx accepts, as client_max_body_size: an upload limit
sites[].nginx.timeoutnumber60Seconds nginx waits on PHP or on the dev server before it answers 504
sites[].nginx.headersmap{}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.namestringnoneThe <NAME> in E2E_<NAME>_URL; default the domain's first label
sites[].e2e.resetstring or falsenoneReplaces migrate:fresh --seed before a suite; false skips it
sites[].e2e.setuplist 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
syncobject{"mode":"auto","exclude":[]}What this project decides about its backend's file sync
sync.modeauto · off"auto"Use the backend's file sync where it has one. off is a plain bind mount. DDEV only
sync.excludelist of string[]Directories kept out of that sync and bind-mounted instead
e2eobject{"directory":null}Where dqx e2e runs a suite of the project's own, rather than in one site
e2e.directory dqx 1.67+stringnullWhere the suite lives, from the project root; outside www/ in a multi project dqx mounts it under /var/www by its last folder name
phplist of string[]php.ini settings this project needs: memory_limit, upload_max_filesize
distro dqx 1.77+alpine · debiannoneThe distro dqx picks for a dqx-php image it chooses: a new site, dqx image switch, a proposal
serviceslist of object[]Services this project runs beside its application: redis, meilisearch, mailpit
services[]objectnoneOne service the project runs beside its application
services[].namestringnoneWhat 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[].environmentmap{}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+booleanfalseJoin 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+booleanfalseInstall this machine's local certificate authority in the container, so it trusts the project's own https domains
installobjectnoneWhich steps dqx install runs for this project
install.gitHooksbooleanfalseWrite git's own pre-commit hook into each checkout that has none
install.envbooleanfalseCreate each site's .env from its example
install.composerbooleanfalseRun composer install in each site
install.npmbooleanfalseRun the node install and build in each site
install.sslbooleanfalseIssue this project's certificate
install.fluxbooleanfalseAuthenticate 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
extrasmap{}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 build

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

reachesSites 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 build

A 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 · upthe environment is serving
schemasdatabases and grants
createscaffold only, before there is a .env
env.env written from the config
setupscaffold only, database reachable
certificateif install.ssl
env · composer · npm · git hooksper site
hooks.postInstallper site, then the project's own
notesprinted, 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.

dqx v1.78.0