Site Management
Commands
| Command | Description |
|---|---|
lerd init | Interactive wizard: choose PHP version, HTTPS, and services, then save .lerd.yaml and apply |
lerd init --fresh | Re-run the wizard with existing .lerd.yaml values as defaults |
lerd park [dir] | Register all Laravel projects inside dir (defaults to cwd) |
lerd unpark [dir] | Remove a parked directory and unlink all its sites |
lerd link [domain] | Register the current directory as a site (domain name without TLD, defaults to directory name) |
lerd unlink | Unlink the current directory site (removes all domains) |
lerd domain add <name> | Add an additional domain to the current site |
lerd domain remove <name> | Remove a domain from the current site |
lerd domain list | List all domains for the current site |
lerd sites | Table view of all registered sites |
lerd open [name] | Open the site in the default browser |
lerd share [name] | Expose the site publicly via ngrok or Expose (auto-detected) |
lerd secure [name] | Issue a mkcert TLS cert and enable HTTPS, updates APP_URL in .env |
lerd unsecure [name] | Remove TLS and switch back to HTTP, updates APP_URL in .env |
lerd pause [name] | Pause a site: stop its workers and replace the vhost with a landing page |
lerd unpause [name] | Resume a paused site: restore its vhost and restart previously running workers |
lerd env | Configure .env for the current project with lerd service connection settings |
Project initialisation
lerd init runs an interactive wizard, writes the answers to .lerd.yaml in the project root, and then applies the configuration: linking the site, enabling HTTPS if requested, picking a database, and starting any required services.
cd ~/Projects/my-app
lerd init? PHP version: 8.5
? Node version (leave blank to skip):
? Enable HTTPS? No
? Database:
> SQLite (no service)
MySQL (lerd-mysql)
PostgreSQL (lerd-postgres)
? Services:
◉ redis
◯ meilisearch
◯ rustfs
◯ mailpit
Saved .lerd.yaml
Linked: my-app -> my-app.test (PHP 8.5, Node 22, Framework: laravel)Wizard defaults are populated intelligently on first run:
- PHP version: from the site registry if already linked, otherwise from
.php-version,composer.json, or the global default - Enable HTTPS: pre-checked if the site is already secured
- Database: pre-selected from any database already in
.lerd.yaml, otherwise fromDB_CONNECTIONin.env(or.env.examplefor a fresh clone), falling back to SQLite (Laravel's default for new projects) - Services: pre-checked based on what's detected in the project's
.envfile (only non-database services here, since the database is its own step)
The Database step is a single choice rather than a multi-select, so picking MySQL automatically deselects SQLite and vice-versa. After the wizard completes, lerd env runs automatically to write your choices to .env:
- MySQL / PostgreSQL:
DB_CONNECTIONand the relatedDB_HOST/DB_PORT/DB_DATABASE/DB_USERNAME/DB_PASSWORDkeys are rewritten to point atlerd-mysql/lerd-postgres, the service is started if it isn't already, and the project database (plus a_testingvariant) is created. - SQLite:
DB_CONNECTION=sqliteandDB_DATABASE=database/database.sqliteare written to.env, and thedatabase/database.sqlitefile is created if it doesn't exist. No service is started.
The choice is authoritative: if .env already had DB_CONNECTION=mysql from a previous setup and you switch to SQLite (or vice versa) in the wizard, lerd skips the auto-detection of the old database and applies your new pick instead.
The same prompt also appears when you run lerd env directly on a project whose .env says SQLite and whose .lerd.yaml doesn't yet have a database picked, for example, after cloning a project that wasn't created with lerd init. The prompt is skipped automatically when stdin isn't a TTY (e.g. lerd setup --all in CI), and for frameworks with explicit env service rules (fw.env.services in the YAML, like Symfony, WordPress, etc.) since those don't use Laravel's DB_CONNECTION convention.
Persistence is one-way: lerd reads the source of truth from .lerd.yaml and writes only to .env. .env.example is never modified; it's only used as a template when .env doesn't exist yet.
The resulting .lerd.yaml is intended to be committed to the repository. On a new machine or after a reinstall, running lerd init again reads the saved file and restores the full configuration without any prompts.
# On a fresh machine, no wizard, config applied directly
git clone ...
cd my-app
lerd initUse --fresh to re-run the wizard while keeping existing values as defaults:
lerd init --freshNon-PHP / custom container sites
For Node.js, Python, Go, or any other non-PHP runtime, lerd builds a dedicated container image per project and has nginx reverse-proxy to it. The workflow differs from PHP sites:
- Create a
Containerfile.lerdin the project root that defines the runtime and start command. - Run
lerd init; it detects the non-PHP project (nocomposer.json) and switches to custom container mode, asking for the port, HTTPS, and services. It writes.lerd.yamlfor you. Alternatively write.lerd.yamlmanually with acontainer: {port: N}section. - Run
lerd link; it builds the image, starts the container aslerd-custom-<sitename>, and generates the nginx vhost.
Important: calling
lerd linkwithout the container config registers the project as a PHP-FPM site (wrong). If that happened, runlerd unlinkfirst, set up the files, thenlerd linkagain.
See Custom Containers for the full configuration reference.
Projects outside the home directory
By default, the PHP-FPM and nginx containers only have access to files under $HOME. If your project lives elsewhere (e.g. /var/www, /opt/projects, /var/local), lerd automatically detects this and adds the required volume mount to both containers.
This happens transparently when you:
lerd linkorlerd parka directory outside$HOME- Run
lerd php,composer,laravel new, or any exec command from an outside path
The containers are restarted once to pick up the new mount. Subsequent commands from the same path run without delay. When you unlink or unpark, stale mounts are cleaned up automatically.
Domain naming
Directories with real TLDs are automatically normalised: dots are replaced with dashes and the TLD is stripped before appending .test.
For example: admin.example.com becomes admin-example.test
Multiple domains
A site can respond to multiple domains. The argument to lerd link is the domain name without the .test TLD; it is appended automatically from the global config.
lerd link myapp # links as myapp.testAfter linking, you can add more domains:
lerd domain add api # adds api.test
lerd domain add admin # adds admin.test
lerd domain list
# myapp.test (primary)
# api.test
# admin.test
lerd domain remove api # removes api.testDomains are stored in .lerd.yaml as an array (without the TLD) so the file stays portable across machines with different TLD configurations:
domains:
- myapp
- adminYou can also manage domains from the web UI: click the pencil icon next to the domain in the site header to open the domain management modal.
When a site is secured with HTTPS, the certificate is automatically reissued to cover all domains.
Subdomains (e.g. anything.myapp.test) are automatically routed to the same site. Git worktree subdomains take priority when they exist.
Domain conflicts
A domain may only be claimed by one site at a time. When lerd link, the watcher's auto-registration, or a .lerd.yaml-driven re-link tries to register a domain that another site already owns, the conflicting domain is filtered out (not the whole site) and a warning is printed:
$ lerd link
[WARN] domain "shared.test" already used by site "owner-app", skipped
Linked: clone-app -> clone-app.test (PHP 8.5, Node 22, Framework: laravel)The site still gets registered with whatever domains survived the filter. If every requested domain is conflicted, lerd falls back to a freshly generated <dirname>.<tld> (with a numeric suffix to avoid name collisions).
.lerd.yaml is never modified when this happens; the original domains: list stays on disk so the conflict is visible to the UI and the entry self-heals on the next link if you remove the owning site. The web UI surfaces filtered domains in two places:
- The site detail header's domain pill shows an amber ⚠️ when one or more declared domains are filtered (
+N morecount includes them). Hovering reveals each conflicted entry with the owning site name. - The Manage Domains modal lists conflicted entries at the top with a warning icon, the domain struck-through, a
used by <site>pill, and a small trash button. Clicking the trash removes the entry from.lerd.yamlonly; the registry, vhost, and certs are untouched.
The conflict check is strict: a domain is reserved regardless of TLS scheme. Two sites cannot share the same domain even if one runs HTTPS and the other HTTP; DNS and browser caches don't reliably disambiguate by scheme, and the resulting setup is fragile.
Custom APP_URL
By default lerd env writes APP_URL=<scheme>://<primary-domain> to the project's .env on every run. If you need to override that (for example to add a path prefix, point at a staging hostname, or pin a specific protocol), set app_url in .lerd.yaml (committed, shared across machines) or in the per-machine site entry in ~/.local/share/lerd/sites.yaml. The precedence chain is:
.lerd.yamlapp_url: committed to the repo, takes effect on every machine.sites.yamlapp_url: per-machine override, useful when only one developer needs a different URL.- The default generator (
<scheme>://<primary-domain>): used when neither override is set.
# .lerd.yaml
domains:
- myapp
app_url: http://myapp.test/apilerd env reads the chain on every invocation, so editing the file and re-running lerd setup (or lerd env directly) is enough to apply the change. If the .lerd.yaml app_url happens to point at a domain that got filtered by the conflict check, lerd silently falls through to the next precedence level so you don't end up writing a DB_HOST of lerd-mysql next to an APP_URL that points at someone else's site.
Workers
The lerd init wizard includes a workers step that lets you select which workers to auto-start when linking. Available workers depend on the framework and what's installed:
- queue: shown when the framework defines a queue worker (replaced by horizon when
laravel/horizonis installed) - horizon: shown only when
laravel/horizonis incomposer.json - schedule: the task scheduler
- reverb: shown only when
laravel/reverbis installed orBROADCAST_CONNECTION=reverbis in.env - custom workers: any additional workers defined in the framework definition
Selected workers are saved to .lerd.yaml:
workers:
- horizon
- scheduleWhen lerd link runs and workers are configured but not yet running, it prompts to run lerd setup so you can install dependencies, run migrations, and start workers in the right order. If workers are already running (re-link), they are left as-is.
lerd setup pre-selects worker steps based on the .lerd.yaml workers list. Workers not in the list still appear in the step selector but are unchecked.
Toggling workers from the CLI (lerd queue:start, lerd schedule:stop, etc.) or the web UI syncs the running state back to .lerd.yaml when the file exists.
lerd check validates that listed workers are valid for the detected framework.
lerd status includes a Workers section showing all active, restarting, or failed workers across sites. In the web UI, failing workers show a pulsing red toggle and their log tab appears with a "!" indicator.
Name collision handling
When a directory is parked or linked and another site is already registered with the same name:
- Same path: treated as a re-link of the same site. The existing registration is updated and the TLS state is preserved.
- Different path: the new site is registered with a numeric suffix (
myapp-2,myapp-3, etc.) so both sites can coexist.
Linking from the web UI
You can link a new site directly from the dashboard by clicking the + button in the sites panel header. A directory browser modal lets you navigate to the project folder and click Link This Directory. After linking, the site's .env is auto-configured and the UI switches to the new site's settings.
Unlinked domains
When you visit a .test domain that isn't linked to any site over HTTP, lerd shows a branded "Site Not Found" page with a link to the dashboard and a retry button. This replaces the browser's generic connection error.
For HTTPS the catch-all uses ssl_reject_handshake on;, so the browser sees a clean ERR_SSL_UNRECOGNIZED_NAME_ALERT connection error rather than a landing page. This is unavoidable: lerd cannot pre-issue a certificate covering arbitrary *.test hostnames because browsers (Chrome especially) reject TLD-level wildcard certificates with ERR_CERT_COMMON_NAME_INVALID. If you're hitting this on a domain you used to have linked, the fix is browser-side (clear site data / unregister the service worker), not server-side.
Unlink behaviour
When you unlink a site that lives inside a parked directory, the vhost is removed but the registry entry is kept and marked as ignored; the watcher will not re-register it on its next scan. Running lerd link in that directory clears the ignored flag and restores the site.
Pausing sites
Pausing a site frees up resources without removing it from lerd. It is useful when you're switching focus between projects and want to stop workers and silence a site without fully unlinking it.
lerd pause # pause the site in the current directory
lerd pause my-project # pause a named siteWhen a site is paused:
- All running workers for that site are stopped (queue, schedule, reverb, stripe, and any custom workers)
- The nginx vhost is replaced with a minimal landing page that shows a Resume button
- Services no longer needed by any other active site are auto-stopped
- The paused state is persisted, so the site stays paused across
lerd start/lerd stopcycles
The landing page's Resume button calls the lerd dashboard API directly, so you can unpause from the browser without opening a terminal.
lerd unpause # resume the site in the current directory
lerd unpause my-project # resume a named siteWhen a site is unpaused:
- The original nginx vhost is restored (including HTTPS if the site is secured)
- Any services referenced in the site's
.envare started - Workers that were running before the pause are restarted
Paused sites still appear in lerd sites output and the web UI. Their status is shown as paused.
Running CLI commands on a paused site
You can run php artisan, composer, lerd db:export, and other exec-based commands on a paused site without unpausing it first. If any services the site needs (MySQL, Redis, etc.) were auto-stopped when the site was paused, lerd starts them automatically before running the command:
$ php artisan migrate
[lerd] site "my-project" is paused, starting required services...
Starting mysql...
INFO Nothing to migrate.On subsequent commands the services are already running, so no notice is printed. The site stays paused; the nginx vhost remains as the landing page and workers are not restarted.
Commands that benefit from this auto-start:
| Command | Notes |
|---|---|
php artisan <args> / lerd artisan <args> | Any artisan command |
php <args> / lerd php <args> | Any PHP script |
composer <args> | Composer via the lerd shim |
lerd shell | Opens an interactive shell in the PHP-FPM container |
lerd db:import | Imports a SQL dump |
lerd db:export | Exports a database |
lerd db:shell | Opens an interactive DB shell |
Git worktrees
Lerd automatically creates a subdomain for each git worktree checkout. See Git Worktrees for details.
Sharing sites
lerd share exposes the current site via a public tunnel. Requires ngrok, cloudflared, or Expose to be installed.
| Command | Description |
|---|---|
lerd share | Share the current site (auto-detects ngrok, cloudflared, or Expose) |
lerd share <name> | Share a named site |
lerd share --ngrok | Force ngrok |
lerd share --cloudflare | Force Cloudflare Tunnel (cloudflared) |
lerd share --expose | Force Expose |
lerd share --localhost-run | Force localhost.run (SSH, no signup) |
lerd share --serveo | Force serveo.net (SSH, no signup) |
A local reverse proxy rewrites the Host header to the site's domain so nginx routes to the correct vhost. Response Location headers and HTML/CSS/JS/JSON body references to the local domain are also rewritten to the public tunnel URL, so redirects and asset links work correctly in the browser.
When the tunnel forwards an X-Forwarded-Host header (the public hostname the visitor actually typed), lerd's generated vhosts propagate it into HTTP_HOST, SERVER_NAME, and the HTTP_X_FORWARDED_* family, so PHP apps that build absolute URLs from $_SERVER or Laravel's url() helper return the public URL instead of the local .test one. See Nginx Overrides for the full mapping, and for how to drop per-site snippets under ~/.local/share/lerd/nginx/custom.d/ without losing them on the next lerd update.