Custom services
Custom services let you define any OCI-based service (MongoDB, RabbitMQ, Soketi, Stripe Mock, etc.) that integrates with lerd service, lerd env, and the dashboard. Related: Service presets for ready-made installers, Services for the built-in list.
Lerd lets you define arbitrary OCI-based services that integrate seamlessly with lerd service, lerd start/stop, and lerd env, without recompiling.
Custom service configs live at ~/.config/lerd/services/<name>.yaml.
Adding a custom service
From a YAML file (recommended for reuse or sharing):
lerd service add mongodb.yamlWith flags (quick one-off):
lerd service add \
--name mongodb \
--image docker.io/library/mongo:7 \
--port 27017:27017 \
--env MONGO_INITDB_ROOT_USERNAME=root \
--env MONGO_INITDB_ROOT_PASSWORD=secret \
--data-dir /data/db \
--env-var "MONGO_DATABASE={{site}}" \
--env-var "MONGO_URI=mongodb://root:secret@lerd-mongodb:27017/{{site}}" \
--detect-key MONGO_URI \
--init-exec "mongosh admin -u root -p secret --eval \"db.getSiblingDB('{{site}}').createCollection('_init')\""Removing a service (custom or default)
lerd service remove mongodb # stops + removes; data preserved
lerd service remove mongodb --purge # also wipes data dirlerd service remove works for any service, including default presets (postgres, redis, mariadb, mysql, meilisearch, mailpit, rustfs). The flow stops the unit if it's running, removes the container, deletes the quadlet, and removes the on-disk config (a no-op for default presets, which are embedded in the binary).
Pass --purge to also wipe the persistent data. The data dir at ~/.local/share/lerd/data/<service>/ is renamed aside to <service>.pre-remove-<timestamp> (a sibling directory), not hard-deleted. To recover, rename it back before reinstalling. The orphaned aside copies can be cleaned up later by hand.
Without --purge, data is preserved and a subsequent lerd service preset install <name> (for default presets) or lerd service add (for custom services) will pick up where you left off.
Reinstalling a service
lerd service reinstall postgres # same version, data preserved
lerd service reinstall postgres --reset-data # same version, fresh datareinstall stops, removes, and reinstalls the service at its current version. Use it when:
- A service update produced data incompatible with the new image and you want a clean slate.
- The container has drifted into a bad state and a full quadlet rewrite would be cleaner than a restart.
--reset-data adds a data-dir rename-aside (same recovery semantics as --purge) and automatically reprovisions linked-site state on the freshly installed service:
- For database families (mysql, mariadb, postgres): each linked site's expected database is created via
CREATE DATABASE IF NOT EXISTS. The database name comes from.lerd.yamldb.database, then.envDB_DATABASE, then the site name with hyphens converted to underscores. - For object-storage families (rustfs): each linked site's expected bucket is created via
mc mb. The bucket name comes from.envAWS_BUCKET, otherwise derived from the site name. - For cache services (redis, memcached): no per-site state to recreate, so reprovisioning is a no-op.
If a single linked site fails to reprovision (e.g. malformed .env), the reinstall continues with the remaining sites and reports the joined errors at the end.
YAML schema
# Required
name: mongodb # slug [a-z0-9-], must match filename stem
image: docker.io/library/mongo:7
# Optional
ports:
- 27017:27017 # host:container
environment: # container environment variables
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: secret
data_dir: /data/db # mount target inside container
# host path: ~/.local/share/lerd/data/<name>/
# omit to disable persistent storage
chown_data: false # add :U to the data_dir mount so podman re-chowns
# the host dir to the container's expected UID at
# mount time. Pair with userns when the in-container
# process runs as a non-root user (e.g. elasticsearch
# UID 1000) and would otherwise hit EACCES.
userns: "" # written verbatim to UserNS= in the quadlet, e.g.
# "keep-id:uid=1000,gid=0" maps the host user 1:1
# to container UID 1000 so bind-mounted volumes are
# writable in rootless podman. Leave empty for
# images that run as root or drop privileges via
# their entrypoint.
exec: "" # container command override
dashboard: http://localhost:8081 # URL shown as an "Open" button in the web UI
# when the service is active
dashboard_external: false # open the dashboard in a new browser tab instead of
# the embedded iframe. Use for admin UIs whose login
# cookie is dropped on cross-origin iframe POSTs and
# has no SameSite override (e.g. RabbitMQ Cowboy).
# External dashboards also skip the sidebar shortcut.
connection_url: mongodb://root:secret@127.0.0.1:27017/?authSource=admin
# host-side scheme URL (mysql://, postgresql://, mongodb://, etc.)
# Surfaced as an "Open connection URL" link on the service detail
# panel when the service is active and no paired admin UI is installed.
# Right-click "Copy link" works; left-click hands the URL to your
# registered DB client (DBeaver, TablePlus, Compass, etc.).
description: "MongoDB document store" # shown in `lerd service list`
# Service dependencies (see "Service dependencies" section below)
depends_on:
- mysql # services that must start before this one
# `lerd service start <name>` recursively starts each dep first.
# `lerd service stop <name>` stops anything that depends on it first.
# Family groups related services so admin UIs can auto-discover every member.
# Built-in mysql / postgres / redis / etc. are always implicitly in the family
# of the same name. Multi-version preset alternates inherit this through the
# preset YAML; hand-rolled custom services can opt in by setting the field.
family: mysql
# Dynamic env vars are computed at quadlet generation time. Currently supported
# directive: discover_family:<name>[,<name>...] which expands to a comma-joined
# list of container hostnames for every installed service in the named families.
# phpMyAdmin uses this to populate PMA_HOSTS with all mysql + mariadb variants.
dynamic_env:
PMA_HOSTS: discover_family:mysql,mariadb
# Injected into .env by `lerd env`
env_vars:
- MONGO_DATABASE={{site}}
- MONGO_URI=mongodb://root:secret@lerd-mongodb:27017/{{site}}
# Auto-detection for `lerd env`
env_detect:
key: MONGO_URI # trigger if this key exists in .env
value_prefix: "mongodb://" # optional: only match if value starts with this
# Per-site initialisation run by `lerd env` after the service starts
site_init:
container: lerd-mongodb # optional, defaults to lerd-<name>
exec: >
mongosh admin -u root -p secret --eval
"db.getSiblingDB('{{site}}').createCollection('_init');
db.getSiblingDB('{{site_testing}}').createCollection('_init')"Site handle placeholders
env_vars values and site_init.exec support two placeholders that are substituted per-project when lerd env runs:
| Placeholder | Expands to |
|---|---|
{{site}} | Project site handle (derived from the registered site name or directory name, hyphens converted to underscores) |
{{site_testing}} | Same as {{site}} with _testing appended |
{{mysql_version}} | Major version of the MySQL service image (e.g. 8.0) |
{{postgres_version}} | Major version of the PostgreSQL service image (e.g. 16) |
{{redis_version}} | Major version of the Redis service image (e.g. 7) |
{{meilisearch_version}} | Version of the Meilisearch service image (e.g. 1.7) |
These are not limited to database names; use them anywhere a per-project identifier is needed (a bucket name, a queue prefix, a namespace, etc.).
How lerd env uses custom services
When lerd env runs in a project directory, it checks each custom service's env_detect rule against the project's .env. If a match is found:
env_varsare written into.env, with{{site}}and{{site_testing}}substituted- The service is started if not already running
site_init.execis run inside the container (if defined)
How lerd start / lerd stop handle custom services
lerd start and lerd stop include any custom service that has a quadlet file installed (i.e. has been started at least once via lerd service start). They are started and stopped alongside the built-in services.
Custom service containers are given a 5-second graceful stop window before podman sends SIGKILL. This keeps lerd service stop and the web UI's Stop button responsive even for images with slow shutdown sequences (Selenium Chromium/supervisord, for example, can otherwise block for 30 s+). On Podman 5.0+ this is emitted as the native StopTimeout=5 quadlet key; on Podman 4.x (e.g. Ubuntu 24.04's 4.9.3) lerd writes PodmanArgs=--stop-timeout=5 instead, since the StopTimeout= key only exists in 5.0+. Existing installs of a slow-stopping service can pick up the change with lerd service remove <name> && lerd service preset <name>.
Pinning services
By default, lerd can auto-stop services that no active site references in its .env. Use pin to keep a service running regardless of which sites are active:
lerd service pin mysql # always keep MySQL running
lerd service pin redisPinning a service also starts it immediately if it is not already running. Unpin to restore normal auto-stop behaviour:
lerd service unpin mysqlPinned services are shown with a [pinned] note in lerd service list and the web UI.
Manually stopped services
If you stop a service with lerd service stop (or via the web UI), lerd records it as manually paused. lerd start and autostart on login will skip it; the service stays stopped until you explicitly start it again.
lerd stop + lerd start restores the previous state: services that were running before lerd stop start again; services you had manually stopped remain stopped.
lerd service list output
Services are shown in a two-column format optimised for narrow terminals. Custom services include a [custom] marker. Inactive reasons and dependency info appear as indented sub-lines:
Service Status
────────────────────────────────
mysql active
redis inactive
no sites using this service
phpmyadmin active [custom]
depends on: mysql- no sites using this service: the service was auto-stopped because no active site's
.envreferences it - depends on: ...: the service has declared dependencies (see "Service dependencies" below)
Service dependencies
Custom services can declare that they need another service to be running first using depends_on. Lerd uses this to automatically manage start and stop order.
Define via YAML:
# ~/.config/lerd/services/phpmyadmin.yaml
name: phpmyadmin
image: docker.io/phpmyadmin:latest
ports:
- 8080:80
depends_on:
- mysql
dashboard: http://localhost:8080
description: "phpMyAdmin web interface for MySQL"Define via flags:
lerd service add \
--name phpmyadmin \
--image docker.io/phpmyadmin:latest \
--port 8080:80 \
--depends-on mysql \
--dashboard http://localhost:8080Behaviour:
| Action | Effect |
|---|---|
lerd service start phpmyadmin | Starts mysql first (if not already running), then starts phpmyadmin |
lerd service start mysql | Starts mysql, then also starts any services that depend on it (e.g. phpmyadmin) |
lerd service stop mysql | Stops phpmyadmin first (cascade), then stops mysql |
Site pause (auto-stops mysql) | phpmyadmin is stopped first, then mysql |
Site unpause (starts mysql) | mysql starts, then phpmyadmin starts |
Multiple dependencies are supported:
depends_on:
- mysql
- redisDependencies can be built-in services (mysql, redis, postgres, meilisearch, rustfs, mailpit) or other custom services.
INFO
Circular dependencies (A depends on B, B depends on A) are not detected at definition time. The start cycle is naturally broken because a service already active is skipped. Avoid circular configurations.
Example: Soketi (Pusher-compatible WebSocket server)
Soketi is a self-hosted Pusher-compatible WebSocket server. Use this if you prefer a standalone container over Laravel Reverb.
# ~/.config/lerd/services/soketi.yaml
name: soketi
image: quay.io/soketi/soketi:latest-16-alpine
description: "Pusher-compatible WebSocket server"
ports:
- 6001:6001
- 9601:9601
environment:
SOKETI_DEFAULT_APP_ID: lerd
SOKETI_DEFAULT_APP_KEY: lerd-key
SOKETI_DEFAULT_APP_SECRET: lerd-secret
env_vars:
- BROADCAST_CONNECTION=pusher
- PUSHER_APP_ID=lerd
- PUSHER_APP_KEY=lerd-key
- PUSHER_APP_SECRET=lerd-secret
- PUSHER_HOST=lerd-soketi
- PUSHER_PORT=6001
- PUSHER_SCHEME=http
- PUSHER_APP_CLUSTER=mt1
- VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
- VITE_PUSHER_HOST="${PUSHER_HOST}"
- VITE_PUSHER_PORT="${PUSHER_PORT}"
- VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
- VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
env_detect:
key: PUSHER_HOST
value_prefix: "lerd-soketi"
dashboard: http://127.0.0.1:9601lerd service add ~/.config/lerd/services/soketi.yaml
lerd service start soketiSoketi metrics UI: http://127.0.0.1:9601
Example: Stripe (Laravel Cashier)
Two services cover the typical Cashier local dev workflow:
stripe-mock: a local Stripe API mock. No Stripe account needed. Use this for feature tests that exercise Cashier without hitting the real API.
# ~/.config/lerd/services/stripe-mock.yaml
name: stripe-mock
image: docker.io/stripemock/stripe-mock:latest
description: "Local Stripe API mock for Cashier testing"
ports:
- 12111:12111lerd service add ~/.config/lerd/services/stripe-mock.yaml
lerd service start stripe-mockPoint the Stripe PHP SDK at the mock in your AppServiceProvider or test bootstrap:
\Stripe\Stripe::$apiBase = 'http://lerd-stripe-mock:12111';Flag reference
| Flag | Description |
|---|---|
--name | Service name, slug format [a-z0-9-] (required) |
--image | OCI image reference (required) |
--port | Port mapping host:container (repeatable) |
--env | Container environment variable KEY=VALUE (repeatable) |
--env-var | .env variable injected by lerd env, supports {{site}} (repeatable) |
--data-dir | Mount path inside the container for persistent data |
--detect-key | .env key that triggers auto-detection in lerd env |
--detect-prefix | Optional value prefix filter for auto-detection |
--init-exec | Shell command run inside the container once per site (supports {{site}} and {{site_testing}}) |
--init-container | Container to run --init-exec in (default: lerd-<name>) |
--dashboard | URL to open when clicking the dashboard button in the web UI |
--description | Description shown in lerd service list |
--depends-on | Service name that must be running before this one (repeatable: --depends-on mysql --depends-on redis) |