Appearance
Databases and services
A project declares the services it needs, and dqx runs them - each with the image it runs on:
yaml
services:
- name: redis
image: redis:7.4
- name: mailpit
image: axllent/mailpit:v1.21Names, or names with images
Two shapes, and they belong to different places. dqx init takes a bare list of names and writes the images for you - --services db,redis,mailpit, or services: [db, redis, mailpit] in a request document. So does a site's own services:, which picks from what the project already runs. The project-level key in .dq/config.yml is the one that carries the image, because that is the file the stack is generated from and a name alone does not say what to run.
The database is not in that list: it comes from database:, which says the server and the version.
Where they are, from two sides
The same service has two addresses, and mixing them up is the most common local-development mistake.
From inside the application - this is what belongs in .env:
ini
DB_HOST=db
REDIS_HOST=redis
MAIL_HOST=mailpitService names on the project's own network. They do not change.
From your machine - this is what a GUI client needs:
bash
dqx db --url
# mysql://user:pass@127.0.0.1:60082/my_appA port of this project's own, the same after every restart. Not 3306, which is the one number every project on the machine would want, and not one the kernel picks either: it is derived from the project, in the range 20000-29999, and it does not move. dqx info lists them all.
The application still uses the service name
db, redis and mailpit are what the container resolves; the host port is for tools running on your machine. An application that talks to 127.0.0.1 works on your laptop and nowhere else.
Asking for a particular one
A script committed beside the project - a screenshot run against the mail catcher, a seeded connection - may need a number it can be written against. Say so in .dq/config.yml:
yaml
ports:
mailpit-dashboard: 8025
db: 3307The keys are what dqx info calls them: db, redis, mailpit, mailpit-dashboard, app, vite. dqx sync regenerates the stack, and dqx up publishes it.
A port is also a fact about one machine, and two projects can want the same one. .dq/compose.env overrides whatever the config says, for you alone:
FORWARD_MAILPIT_DASHBOARD_PORT=8026Your saved connections move once
Ports were assigned by docker until dqx 1.45, so the first dqx up after upgrading gives every project the numbers it keeps from then on. A saved database connection needs correcting that once, and never again. dqx info prints them.
The database
Schemas and the project's user are created on every install, not only the first. A container's own initialisation runs once, ever - so a schema added later, or a password changed later, would leave an application unable to connect with nothing saying why.
bash
dqx db # a client
dqx db --url # the URL, which stays the URL
dqx db:import dump.sql # and db:export
dqx snapshot # database and storage together
dqx restoreA project can declare more than one schema, and a project serving several sites gives each its own.
A second database on one checkout
Sometimes the same code needs a second database. A browser suite that registers users and creates records wants a database it may wipe, and yours is not it.
Two sites on one checkout is the answer. The second declares no repository of its own: the first one already clones the code, and directory points at that same checkout.
yaml
sites:
- domains: { primary: example.dqx }
database: example
repositories: [{ repository: example-app }]
- domains: { primary: e2e.example.dqx }
directory: example.dqx # the same checkout, not a second copy
database: example_e2e
env:
DB_DATABASE: example_e2e
APP_URL: https://e2e.example.dqx
REDIS_DB: "2" # sessions and queues apart from development
REDIS_CACHE_DB: "3"
CACHE_PREFIX: example-e2e
QUEUE_CONNECTION: synchttps://e2e.example.dqx now serves the code you are editing, out of example_e2e. Reset it whenever you like, because it is nobody's data:
bash
dqx artisan --site e2e.example.dqx migrate:fresh --seedenv is what makes it a second database, not database
database creates and grants the schema; env.DB_DATABASE is what the application opens. A checkout has one .env, and it belongs to the site that clones into it, so without env the second site is served out of the first one's database. It looks perfectly fine while it wipes your data. dqx doctor reports a site that owns a schema and hands it to nobody.
Requests get these values as fastcgi_param from the site's own server block, and dqx exec --site, dqx artisan --site and that site's daemons export the same ones, so what a page reads and what migrate:fresh resets are always the same schema. It works because Laravel's Dotenv is immutable: a value already in the environment wins over the file. That also means the site must run without config:cache, which is how dqx runs development anyway.
What else belongs in there
Redis, if the project keeps anything in it. Sessions, cache and queues all live under a database index and a prefix, and sharing those is the same mistake as sharing the schema: a migrate:fresh --seed leaves your development session pointing at a user who no longer exists, and the other way round. Give the second site its own REDIS_DB, REDIS_CACHE_DB and CACHE_PREFIX.
The host, if the application resolves anything from it. A multi-tenant application picks the tenant out of the domain, so the second site's domain has to be one it knows about, through whatever key the project uses for that. Without it every request is a 404 and nothing says why.
Mail is shared. One mailpit for the project, so a journey that reads mail should filter by recipient rather than take the newest message.
An empty value is a value
env('THING', 'a default') returns '', not the default, once THING exists at all. So dqx drops empty entries rather than passing THING= through: writing one would not leave that setting alone, it would blank it. Leave the key out to mean "leave it alone".
dqx test is deliberately left out of this
The PHP test suite gets its database from the project's own phpunit.xml, normally <db>_test, and a process variable beats that file too. If dqx exported the site's environment into dqx test, a suite using RefreshDatabase would wipe the browser database instead. So dqx exec, dqx artisan and dqx shell carry these values, and the test runner does not.
dqx 1.47+
sites[].env needs dqx 1.47. An older dqx does not fail on a key it cannot read, it ignores it, which here means serving the second site out of the first one's database. So the config carries a requires: line for it.
Mail
mailpit catches everything the application sends and shows it in a browser - dqx info prints the URL. Nothing leaves the machine, which is the point.
Search and cache
redis, meilisearch and elasticsearch are declared the same way. Each is only started for a project that asks for it, and a site can opt out of a service the project runs - which matters more than it sounds: an Elasticsearch container costs roughly a gigabyte, and it is worth not paying for it four times in a project where two sites use it.
yaml
sites:
- domains: { primary: api.example.dqx }
services: [db, redis] # this site's, out of the project'sServices that keep data
A declared service whose data must survive a recreate gets a directory under docker/data/ by itself. Today that is Typesense (typesense/typesense:*): ./docker/data/typesense:/data, and TYPESENSE_DATA_DIR=/data. Without it, a command naming --data-dir /data exits with "Data directory /data does not exist", and every recreate takes the search index with it.
Daemons
A queue worker or a scheduler is a long-running process, not a service:
yaml
daemons:
- horizon # a preset: php artisan horizon
- { preset: queue, queues: [default, mail] }
- { preset: schedule, autostart: false } # declared, off until somebody runs it
- name: exports
command: php artisan exports:watch # anything that stays in the foreground
- name: ssr
image: node:24-alpine # another runtime, without the site's checkout
command: node server.mjs| Preset | Command | Healthcheck | Stops within |
|---|---|---|---|
horizon | php artisan horizon | php artisan horizon:status | 60s |
queue | php artisan queue:work --tries=3 (--queue= from queues) | none | 60s |
schedule | php artisan schedule:work | none | 30s |
reverb | php artisan reverb:start --host=0.0.0.0 --port=8080 | port 8080 answers | 10s |
ssr | php artisan inertia:start-ssr | port 13714 answers | 10s |
command, stopGracePeriod and healthcheck (a compose test, or false) override the preset's. restartOnChange says whether it follows the code (below): true for a preset, false for a command of your own or a daemon on another image.
dqx runs each one in a container of its own beside the application, on the site's image (the dqx-php images, the older ones or a project's own; see PHP images) unless it names one. It is not waited for at startup - a worker cannot be healthy before the application it runs exists, which on a fresh install has not been installed yet. A stopping daemon gets SIGTERM through an init process and its grace period, so Horizon and queue:work finish the job they are on.
bash
dqx daemon # what runs, whether it should, its health
dqx daemon off horizon # not for me: stopped now and on every dqx up
dqx daemon on schedule # for me, although the config says autostart: false
dqx daemon restart horizon # finishes its job, then starts again
dqx daemon restart --changed # every daemon on code older than its checkout's
dqx daemon logs horizondqx daemon shows each one's state, health, memory and whether it runs code from before the last pull. A worker boots the framework once and keeps that code: "older" means it started before the last pull, checkout or commit of its site or of one of the project's packages, or before the last composer install or update (dqx reads vendor/composer/installed.json). dqx packages pull, dqx packages switch and dqx composer install|update|require|remove restart those daemons themselves:
bash
dqx composer update laravel/framework
# ...
# → restart horizon: its code changed since it startedComposer run another way, in dqx shell or by a script, does not restart anything by itself. Let the project's own composer.json do it: these commands run inside the app container and tell the worker through the cache, after which it stops at the end of its job and Docker starts it again on the new code.
json
{
"scripts": {
"post-install-cmd": ["@php artisan horizon:terminate || true"],
"post-update-cmd": ["@php artisan horizon:terminate || true"]
}
}For queue:work use @php artisan queue:restart || true instead. Both reach the worker through a shared store (redis, or the cache's database), never array. The scripts also run wherever else composer install runs: in a deploy restarting the workers is the usual step anyway, and || true keeps a CI pipeline without redis from failing on it. After a plain git pull, run dqx daemon restart --changed, or let git do it with a git hook. It has to be a script, which runs on your machine where dqx is; a command would run inside the site:
yaml
sites:
- domains: {primary: app.example.test}
hooks:
git:
post-merge: [{script: .dq/hooks/restart-daemons.sh}]
post-checkout: [{script: .dq/hooks/restart-daemons.sh}]bash
#!/usr/bin/env bash
# .dq/hooks/restart-daemons.sh
dqx daemon restart --changedThe dashboard has the same in a site's menu: a daemons column with on/off and restart, and a warning mark on one that is unhealthy, stopped while it should run, or behind the code.
on and off are yours alone, kept in .dq/config.local.yml; start and stop only touch the container. Name the site with --site where two sites declare a daemon by the same name.
On DDEV and lerd every daemon runs inside their own PHP container and starts with the project, so what is docker's alone does not apply there: on/off, the grace period, the health check and the restart on change. A daemon with autostart: false or an image of its own is left out of their config; dqx doctor names each one and what to do instead.
| docker | DDEV | lerd | herd | |
|---|---|---|---|---|
| Runs as | a container per daemon | web_extra_daemons | custom_workers | reported, start it by hand |
autostart: false | behind a profile, dqx daemon start | left out | left out | - |
image | its own container | left out | left out | - |
| on/off, grace, health, restart on change | yes | no | no | no |
On docker it waits for that itself: while the checkout has a composer.json and no vendor/autoload.php, the container sleeps and logs waiting for composer install once, and the command starts as soon as composer install has run. Before, it crash-looped on vendor/autoload.php until then. A daemon without a preset's health check gets none: the app image's checks php-fpm, which says nothing about a worker.
A daemon dqx generated is regenerated on every dqx sync, so a changed command reaches the stack. One the project wrote into its compose file itself is kept as written.
Declare the package too
A worker whose package is not installed restarts forever while every page answers 200. dqx doctor reports a preset whose package composer.lock lacks (horizon needs laravel/horizon, reverblaravel/reverb, ssr inertiajs/inertia-laravel), and the fix is to install what it needs.
dqx doctor also says:
- a site with a real
QUEUE_CONNECTIONand no daemon, with the one its packages ask for:horizonwhen composer.lock haslaravel/horizon, elsequeue; - a real queue whose every worker you switched off:
jobs will pile up, dqx daemon on horizon; - on docker, a daemon you want that has no container yet, and one on code older than its checkout.
A daemon you switched off is not reported as a stopped service. dqx init and dqx adopt end with the daemons the checkouts ask for and the config does not declare, as a line to add, never written for you.
Opening one
bash
dqx inbox # the mail this project sent, in your browser
dqx redis # redis in TablePlus, database 0
dqx redis --db 2 # another one
dqx db # the database, in TablePlusEach asks the backend which host port it published rather than remembering one, so a project on DDEV and a project on compose both open without anyone knowing the difference. Add --url to print the address instead of opening it.
A backend that does not publish these says so rather than guessing - DDEV routes its own mail catcher through its router, and Herd runs on the machine itself.
The connection URL contains a password
dqx db --url prints, and the dashboard's database button opens, a URL of the form mysql://user:password@127.0.0.1:port/schema. The password is the project's local one from its own stack, and it is in that string: a URL pasted into a chat, a ticket or a screenshot carries it.
It also travels as a command-line argument to the client, and command lines are readable by other processes on the machine for as long as the client is starting. These are local development credentials in a container, on a single-account Mac, so the exposure is small - but the next client integration should prefer a file-based handoff where the application offers one, rather than widening this.