Nginx Overrides
Lerd generates one nginx config per site under ~/.local/share/lerd/nginx/conf.d/{domain}.conf. These files are fully managed: lerd link, lerd secure, lerd site rebuild, and every lerd install (including the one that runs at the end of lerd update) regenerate them from the built-in templates. Any edits made directly to those files are overwritten.
To add per-site directives that survive every regeneration, drop a snippet in ~/.local/share/lerd/nginx/custom.d/, or edit it from the web UI. Because editing nginx is something you reach for rarely, it is no longer a tab: open the site detail page and click the sliders button at the end of the address bar to open the editor in a large modal. The editor is the same surface as the Env tab: changes go through a confirmation modal with an optional "back up the current file first" checkbox, and if a backup exists a Restore button opens a diff modal before rolling the override back. Backups are written to a sibling custom.d.bkp/ directory (the live custom.d/ is auto-included by every site vhost via a {domain}.conf* glob, so backups have to live outside of it to avoid being loaded as duplicate directives) and the most recent one is consumed when you restore. Every save runs nginx -t inside the lerd-nginx container before the new bytes are committed to the live config: if validation fails, the file is rolled back to its previous contents (or removed if there was no previous file), the staged backup is dropped, and nginx's diagnostic is shown in the save modal so you can fix the line and retry without leaving the live nginx config broken.
Worktrees
Each worktree is served on its own subdomain ({branch}.{primary}.test) with its own generated vhost, so it also has its own override at custom.d/{branch}.{primary}.test.conf. When you create a worktree, lerd seeds that file once from the main branch's override, so the worktree starts with the same custom directives the main branch has. After that the two are independent: opening the editor while a worktree tab is selected (the sliders button shows the worktree's domain in the address bar) edits only that worktree's file, and saving reloads nginx for that subdomain alone. Removing a worktree deletes its override and its backups along with the vhost, so deleted branches leave nothing behind. The main branch's override is never touched by any of this.
From the CLI and MCP
The same override is reachable without the web UI, which is handy for scripting or from an agent. lerd nginx show [site] prints the current override (--path prints just the file path), lerd nginx edit [site] opens it in $EDITOR and then validates with nginx -t and reloads on save, and lerd nginx reset [site] deletes it and falls back to the bundled defaults. Add --branch <name> to any of them to target a worktree's override instead of the main branch's. The MCP site_nginx tool mirrors this with action: read | write | reset, an optional site, an optional branch, and content for writes; writes run the same nginx -t validation, backup, and reload as the web editor. All three surfaces go through one shared edit service, so validation, backups, and reload behave identically whichever one you use.
How it works
Every generated site vhost ends with:
include /etc/nginx/custom.d/{your-domain}.conf*;The trailing * makes the include a glob, so nginx treats a missing override file as empty (no 500). The directory is bind-mounted read-only into the lerd-nginx container and is never touched by lerd after creation.
Request timeouts
By default nginx waits 60 seconds for a request to complete before returning 504 Gateway Timeout. Apps with deliberately long-running requests (heavy reports, slow third-party calls, hardware that answers on its own schedule) need that raised, and this does not need a custom.d snippet.
Set request_timeout in seconds and lerd writes it straight into the generated vhost. Globally, in ~/.config/lerd/config.yaml:
nginx:
request_timeout: 300or per project in .lerd.yaml, which overrides the global value and travels with the repo:
request_timeout: 300lerd renders this into fastcgi_read_timeout and fastcgi_send_timeout for PHP-FPM sites, and into proxy_read_timeout and proxy_send_timeout for proxy, FrankenPHP, and custom-container sites, so the same setting works whatever runtime the site uses. Run lerd link (or lerd install) afterwards to regenerate the vhost and reload nginx.
A long nginx timeout only helps if PHP is allowed to run that long too. For requests that are CPU-bound rather than waiting on I/O, also raise max_execution_time in the per-version php.ini via lerd php:ini <version>.
Example: raise the upload limit for one site
Create ~/.local/share/lerd/nginx/custom.d/bigapp.test.conf:
client_max_body_size 200m;Then reload nginx so the include picks it up. The custom.d directory is read by the nginx container, so restart that container (lerd restart only restarts a site's PHP-FPM container and will not pick up a custom.d change):
systemctl --user restart lerd-nginxThat's it. The snippet is merged into the generated server block for bigapp.test and nothing lerd does afterwards (including a version upgrade) will touch it.
Scope
Lines you put in custom.d/{domain}.conf land inside the site's server { ... } block, so you can use anything nginx allows at server level: client_max_body_size, add_header, extra location blocks, proxy_pass overrides, rewrite, and so on.
If you need directives at http {} level (gzip, proxy buffers, a global client_max_body_size, a new map), edit the global override from the web UI: open System → Nginx and pick the Config tab. It edits ~/.local/share/lerd/nginx/http.d/zz-lerd-user.conf, which the generated nginx.conf includes last inside its http {} block, after lerd's own settings, so your values win. The editor is the same surface as the per-site one: Save runs nginx -t before the new bytes are committed, there is an optional backup-first checkbox with a Restore button, and Reset drops the file back to empty. The http.d directory is bind-mounted read-only into the lerd-nginx container and lerd never writes into it itself.
Prefer keeping it on disk? Drop a snippet into ~/.local/share/lerd/nginx/conf.d/ with a filename that starts with an underscore (e.g. _myorg.conf). Files in conf.d/ that lerd does not know about are left alone during regeneration.
Customising the catch-all (_default.conf)
The catch-all vhost lerd ships for unlinked .test domains lives at ~/.local/share/lerd/nginx/conf.d/_default.conf. Editing it directly is supported: lerd stamps a hash sidecar (_default.conf.lerd-managed-hash) when it first writes the file, then compares your on-disk content to that hash on every subsequent lerd start. If the hashes match, lerd keeps the file in sync with template changes; if they differ, your edit is preserved and the next start logs that it skipped the rewrite. Delete the conf (or the sidecar) to restore lerd's default. A common reason to edit it is swapping ssl_reject_handshake on; for ssl_reject_handshake off; on a staging machine where you want unlinked HTTPS hostnames to receive a 444 close rather than a TLS alert.
Forwarded headers and tunneling
The generated vhosts already set the X-Forwarded-* family for you so tools like lerd share, ngrok, and cloudflared work out of the box:
| Forwarded source | Where it comes from |
|---|---|
HTTP_HOST, SERVER_NAME, HTTP_X_FORWARDED_HOST | $http_x_forwarded_host, falling back to $host |
HTTP_X_FORWARDED_PROTO | $http_x_forwarded_proto, falling back to $scheme |
HTTP_X_FORWARDED_PORT | $server_port |
HTTP_X_REAL_IP, HTTP_X_FORWARDED_FOR | $remote_addr |
The fallbacks are declared once in conf.d/_forwarded.conf (generated by lerd at install time) via two map blocks that produce $real_forwarded_host and $real_forwarded_proto. Direct browser requests without X-Forwarded-* headers keep seeing the real host and scheme; tunneled requests see the public hostname the tunnel received. PHP apps that call url() or read $_SERVER['HTTP_HOST'] get correct absolute URLs in both paths without any app-side changes.