Appearance
nginx and response headers
Every site on the docker backend is served by nginx, from a server block dqx generates. You don't edit that file: you say what a site needs in .dq/config.yml, run dqx sync, and dqx writes the file, checks it and reloads nginx.
What every site gets without asking
| Upload limit | 64 MB (client_max_body_size 64m) |
| Response headers from PHP | room for 32 KB of them, not nginx's 4 KB - a Laravel page with a CSP header and two cookies otherwise answers 502 |
| CORS | none from nginx: the application sends its own, and nginx answers no preflight for it |
| PHP's version banner | off - no X-Powered-By: PHP/8.x on every response |
| https | behind the shared router, with PHP told the scheme it cannot see |
Changing it for a site
Under the site, in .dq/config.yml:
yaml
sites:
- domains:
primary: api.acme.dqx
repositories:
- repository: git@ssh.bitbucket.org:acme/api.git
nginx:
maxBodySize: 200m # uploads up to 200 MB
timeout: 300 # an import that takes five minutes
headers:
X-Frame-Options: DENY
Content-Security-Policy: "default-src 'self'"| Key | What it does | Default |
|---|---|---|
nginx.maxBodySize | The largest request body nginx accepts: 64m, 512k, 1g. PHP and proxied sites; a built SPA takes no uploads | 64m |
nginx.timeout | Seconds nginx waits on PHP, or on the dev server of a proxied site, before it answers 504 | 60 |
nginx.headers | Headers added to every response the site serves, error pages included | none |
nginx.cors | open has nginx allow the calling origin with credentials, every method and the requested headers, and answer every preflight itself. An object sets each part: see CORS | off |
nginx.frontController | The PHP file every route that is not a file goes to, from the docroot: /cms/_hubfile.php for a legacy app that does not route through index.php. A folder goes there too, as in the app's own nginx: no directory index | /index.php |
nginx.fastcgiParams | fastcgi_param lines for the site's PHP requests, such as PHP_VALUE: "error_reporting=E_ERROR;". For what the site's environment does not carry | none |
Leave out what you don't change: a key on its default is never written back into the file.
A docroot is served as a static site only when it holds an index.html and no PHP file at all, and never when the site names a frontController. dqx adopt reads both keys from an old server block: the fallback of location / and the PHP location's fastcgi_param lines with a value of their own.
Headers go into every location. nginx passes a header on only to a location that sets none of its own, so one written once at the top of the server block quietly misses the PHP responses. dqx writes each header into each location it generates, with always, so a 404 or a 500 carries it too.
Headers are for what the application doesn't send. nginx adds, it never replaces: a header the application sends as well arrives twice, and for Access-Control-Allow-Origin a browser then refuses the response outright. That's why a CORS header beside cors: open is refused when the config is read.
CORS
cors: open is the short form, for a SPA on another domain that calls this site's API with cookies: nginx answers the calling origin, with credentials, every method and whatever headers the preflight asks for, and answers the preflight itself with 204.
Write an object to change part of that. What it leaves out stays what open does:
yaml
sites:
- domains: { primary: api.acme.test }
nginx:
cors:
origins: [https://portal.acme.test, http://localhost:5173] # only these; default `reflect`
methods: [GET, POST, OPTIONS]
headers: [Content-Type, X-XSRF-TOKEN] # default `reflect`
exposeHeaders: [X-Request-Id]
maxAge: 600 # seconds a preflight is cached| Key | Default | |
|---|---|---|
origins | reflect | reflect answers whichever origin calls, * any origin, or a list: an origin not on it gets no Access-Control-Allow-Origin, so the browser blocks it |
credentials | true | Sends Access-Control-Allow-Credentials: true, so the browser may send cookies |
methods | GET, POST, PUT, PATCH, DELETE, OPTIONS | What a preflight may ask for |
headers | reflect | reflect allows the headers the preflight asks for, or a list |
exposeHeaders | none | Response headers the page's script may read |
maxAge | none | Access-Control-Max-Age, in seconds |
No * beside credentials. With credentials a browser reads * literally in every one of these headers and refuses the answer, so dqx refuses * in origins, methods, headers or exposeHeaders while credentials is true. Set credentials: false for an API without cookies.
A list of origins becomes a map in docker/conf/nginx/dqx-maps.conf, one per site. Only the docker backend writes nginx's CORS block; the others say so and leave CORS to the application.
Applying it
bash
dqx sync --dry-run # which files would change
dqx sync # write them, test them, reload nginxdqx sync rewrites the site's file, runs nginx -t in the project's web container, and reloads only when nginx accepts the whole set. What it says:
text
replaced docker/conf/nginx/sites-api.conf (previous kept as docker/conf/nginx/sites-api.conf.bak)
nginx reloaded in the web containerWhen the project isn't running there's nothing to reload, and the new file is read on the next dqx up:
text
the project is not running - nginx reads the new site configs when it startsWhen nginx refuses the files, sync puts the previous ones back, so a broken file can't stop every site on the next start, and exits non-zero with nginx's own words and the file on your machine:
text
✗ nginx refused the new site configs, so the previous ones are back:
unknown directive "bogus_directive" in ~/dockers/acme/docker/conf/nginx/zz-broken.conf:1Then check the response itself:
bash
curl -sI https://api.acme.dqx | grep -i x-frame-optionsor the Network tab of the browser's developer tools, on the response headers of the request that matters.
Where the files are, and whose they are
In a project with a wrapper, docker/conf/nginx/sites-<repository>.conf. In a single-repository project, the same path under .dq/. Both are generated and gitignored: the config is what's committed, so every copy of the project gets the same nginx.
Every generated file starts with a # generated by dqx line. While it's there the file is dqx's: sync replaces it and keeps the previous one as .bak. Delete that line and the file is yours - never replaced again, and sync says kept … - it differs from what dqx would generate. dqx sync --force replaces it once anyway, with a .bak.
A site file the old dq tool wrote has no such line. If it's byte for byte what that tool generated, nobody touched it, and sync replaces it like its own. One changed character and it's kept as your edit.
A file you take over stops getting every later fix - the buffers, the CORS change, the next one. Use the keys above, or a snippet, before taking a file over.
dqx doctor lists every site file that's taken over, and what of it the config or a snippet could carry instead:
text
nginx docker/conf/nginx/sites-app.conf is taken over: dqx no longer updates it
config could say it: nginx: { maxBodySize: 75m, timeout: 300 }
only a snippet can hold: location ~ ^/dossiers/[^/]+/download$ {It warns when such a file isn't committed, because a fresh clone then gets dqx's file instead. dqx adopt makes the same proposal as part of the config it writes: a site's upload limit and timeout become keys, and each extra location is named for its snippet. A value set inside one of those locations stays with it - it's never lifted to the whole site.
A CORS block written by hand (an Access-Control-Allow-Origin header outside such a location) becomes nginx.cors: open, listed under "Found in the project itself". Check it against the CORS section when the old block was narrower than that.
Lines of your own: the snippet
For what no key covers - an extra location, a rewrite, buffering off for one streaming download - write the lines in .dq/nginx/<primary domain>.conf:
nginx
# .dq/nginx/api.acme.dqx.conf
location ~ ^/dossiers/[^/]+/download$ {
fastcgi_buffering off;
fastcgi_read_timeout 600s;
fastcgi_pass acme_app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}The file is yours: it's committed with the project, in both layouts, and dqx never writes it. dqx sync pastes it into the site's server block between two marker comments, before dqx's own locations, so a location ~ of yours wins over the PHP one when both match. Write it at the left margin, as the inside of a server { } block; dqx indents it.
Run dqx sync after every change to it - nginx reads the generated file, not the snippet. A typo is caught by nginx -t before anything reloads: the previous file stays in place, and sync names the snippet along with nginx's message. dqx doctor lists the snippets in use and warns about one that names no site, which is what's left after renaming a domain by hand. dqx domains --write renames the snippet with the domain.
A snippet lets you stay on the generated file, and keep every later fix, where taking the file over would freeze it.
Other backends
DDEV and Lerd generate their own nginx and each has a slot for a site's own lines, which dqx fills from the same keys and the same snippet. Herd has none, so nothing reaches it.
maxBodySize, headers | timeout | snippet | cors: open | frontController, fastcgiParams | |
|---|---|---|---|---|---|
| docker | yes | yes | yes, before dqx's locations | yes | yes |
| DDEV | yes, in .ddev/nginx/dqx-<domain>.conf | not on DDEV's own server block, whose PHP location sets 10 minutes; yes on a proxied site and an extra docroot, whose blocks dqx writes | yes, at the end of the server block | no | not on DDEV's own server block; yes on an extra docroot |
| Lerd | yes, in ~/.local/share/lerd/nginx/custom.d/<domain>.conf | yes, as request_timeout in .lerd.yaml | yes, at the end of the server block | no | no |
| Herd | no | no | no | no | no |
What a backend can't carry is said by dqx sync and by dqx doctor, never dropped silently:
text
! api.acme.dqx: not applied on ddev: nginx.timeout - DDEV's own PHP location sets fastcgi_read_timeout 10m- DDEV reads its nginx config when the project starts:
ddev restartafterdqx sync. - Lerd:
dqx synctests the files withnginx -tin Lerd's nginx container and reloads it, or puts the previous files back. In.lerd.yaml, which Lerd rewrites itself,dqx syncupdates only the keys dqx writes -request_timeoutamong them - and leaves any other key as it is. - On DDEV and Lerd the snippet goes at the end of the server block, after the backend's own PHP location. A
location ~of yours that both could match is then not the one nginx picks: prefer an exactlocation =or a prefixlocation ^~there.