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.
A renamed directory only reads back under the image that wrote it, so before wiping the data lerd first takes a snapshot of every database on the service, named pre-remove-<timestamp>. That is what lerd db:restore can bring back later, whatever version the service ends up on. See snapshots before a data wipe.
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, including the pre-wipe snapshot, named pre-reset-data-<timestamp> here).
Every reinstall reprovisions linked-site state on the service that comes back, with or without --reset-data. So does starting a service, which is where a two-step install (lerd service preset <name> then lerd service start <name>) first has something running to provision against:
- 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 over the S3 API. 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.
Every entity is looked up before it is created, so reprovisioning a service whose data survived leaves it untouched and reports nothing. If a single linked site fails to reprovision (e.g. malformed .env), the run continues with the remaining sites and reports the joined errors at the end.
Tuning a service
lerd service config mariadb # open the tuning override in $EDITOR, then restart
lerd service config mariadb --path # just print the file path (no editor, no restart)
lerd service config mariadb --no-restartlerd service config opens a user-editable tuning override for the service in $EDITOR. Lerd seeds the file once with a commented template and never overwrites it afterward, so your edits survive lerd service reinstall and lerd update. The override is bind-mounted after the bundled preset config, so any value you set wins. Saving restarts the service so it re-reads the config.
Works for both custom services and built-in default presets (e.g. lerd service preset install mariadb then lerd service config mariadb). The bundled mysql, mariadb, redis, and postgres families ship with a built-in tuning mount. Postgres needs a small wrapper: a bare -c include_dir=... is rejected at runtime, so lerd points postgres at a managed config_file that loads the cluster's own postgresql.conf first and then your override directory, additive, so your values win. For any other image, declare your own tuning mount in the service YAML with a tuning: block:
name: my-cache
image: docker.io/library/memcached:1.6-alpine
tuning:
target: /etc/memcached.conf # required, where the override mounts in the container
template: | # optional, the seed body shown on first edit
# Lerd user tuning for memcached
# Uncomment, tune, then save to apply.
# -m 128
command: memcached -f /etc/memcached.conf # optional, only when the image needs to be told to read the fileThe inline tuning: block exposes the Config tab and the lerd service config <name> CLI for any custom service. target is required; if your image already auto-includes its target path, leave command empty (mysql / mariadb work this way). Set command when the image loads no config by default (redis is the built-in example). Inline tuning wins over the family-keyed defaults, so you can override the bundled mysql/mariadb/redis paths if you ship a non-standard image.
The service must already be installed, running lerd service config <name> against a service whose quadlet isn't on disk errors out with a hint to lerd service preset install <name> first, rather than silently reinstalling it as a side effect of an edit.
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
stop_timeout: 0 # seconds podman waits after SIGTERM before SIGKILL.
# 0 uses the 5s default, which suits images that exit
# promptly. Raise it for engines that flush on shutdown
# (a database killed mid-checkpoint replays its WAL on
# the next start). The unit gets this plus 15s.
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. External dashboards on a
# user-defined service 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
# EnvRole names the service this one is a drop-in for, so a framework that wires up
# `mysql` wires this service up too, pointed at its own container. Only needed when
# the relationship crosses families (mariadb -> mysql, valkey -> redis); a service in
# the same family as the one the framework maps is matched on that alone. It is read
# on frameworks whose env file is not dotenv, where only the framework knows the keys.
env_role: mysql
# Tuning exposes the Config tab + `lerd service config` for this service.
# Only target is required; template seeds the first-edit body, command sets
# the container Exec when the image needs to be told to read the file
# (mysql/mariadb auto-include their conf dir; redis needs `redis-server <path>`).
# When set, inline tuning wins over the family-keyed defaults.
tuning:
target: /etc/memcached.conf
template: |
# Lerd user tuning for memcached.
# -m 128
command: memcached -f /etc/memcached.conf
# Dynamic env vars are computed at quadlet generation time. Supported
# directives:
# discover_family:<name>[,<name>...] — comma-joined hostnames for every
# running member of the named families (phpMyAdmin PMA_HOSTS).
# repeat_family:<families>=<value> — N copies of <value> matching that host list.
# A directive this binary does not know is warned about and skipped, so a store
# definition written for a newer lerd still starts on an older one.
dynamic_env:
PMA_HOSTS: discover_family:mysql,mariadb
# Numbered env var sets for tools that read one variable group per connection:
# <key>: <families>=<template> writes <key>_1..<key>_N, one per running member,
# with {host} / {name} expanded (RedisInsight RI_REDIS_HOST_1, _2, ...). The
# unsuffixed <key> in environment is dropped when this resolves; keep it there
# as the single-connection fallback, because binaries that predate expand_env
# ignore this whole block and serve the static value instead.
expand_env:
RI_REDIS_HOST: redis,valkey={host}
# A single-host admin UI just pins the canonical dependency host in plain
# environment; lerd retargets lerd-<dep> to the actual satisfier (Valkey for
# redis) at quadlet generation, so no special directive reaches an old binary.
environment:
RI_REDIS_HOST: lerd-redis
ME_CONFIG_MONGODB_URL: mongodb://root:lerd@lerd-mongo:27017/?authSource=admin
# 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')"Bundled admin dashboards embed in place
The bundled admin dashboards (phpMyAdmin, pgAdmin, Mongo Express, RabbitMQ, RedisInsight) ask to be proxied, and lerd does not send you to a new tab for them. lerd-ui serves their UI same-origin under /_svc/<service>/, so their session and consent cookies stay first-party and the dashboard embeds in the in-app overlay with a sidebar shortcut like every other service. Without it a browser treats the embedded UI as third-party and withholds the cookie, which leaves the dashboard rendering but failing every form it posts. The new-tab behavior above applies only to your own custom services.
Two fields ask for this, and a preset picks one by where its mount path comes from. A preset that carries its own (RabbitMQ's management.path_prefix, phpMyAdmin's Apache alias) uses dashboard_external, which every lerd understands. A preset whose mount path lerd supplies at generation time (pgAdmin's X-Script-Name header, Mongo Express's base-URL env) uses dashboard_proxy instead, because a lerd released before that wiring existed would otherwise proxy the dashboard without ever telling the upstream it moved, and serve you the upstream's own 404. A lerd that predates dashboard_proxy ignores the field and leaves the dashboard exactly as it was.
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. After the bulk start, lerd refreshes any discover_family and pinned-dependency-host consumers (phpMyAdmin, pgAdmin, RedisInsight, mongo-express) so their host lists include engines that came up in the same pass, otherwise a pre-start reconcile can leave PMA_HOSTS empty when MariaDB was not running yet.
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>.
Five seconds suits an image that exits as soon as it is asked to, but not one that has to finish writing first. A database checkpointing a large buffer pool needs longer, and being killed part-way through leaves its data files dirty, so the next start spends minutes replaying the write-ahead log. Those services declare their own window with stop_timeout:
stop_timeout: 60The unit that runs the container is given that window plus fifteen seconds, because podman only starts counting once the stop reaches it and still has to reap and remove the container afterwards. That matters more than it sounds: a unit inherits DefaultTimeoutStopSec when nothing sets it, and Arch-family distributions ship that at 10 seconds, so without the longer unit timeout systemd would SIGKILL the stop long before podman had spent the grace the service asked for.
Lerd waits out that same window whenever it starts or restarts the service too, not only when it stops it. systemd queues a start behind a stop that is already running, so a start issued during one spends the stop's window before its own work begins. Giving it the shorter default made commands fail on a service that was coming back perfectly well: a lerd db:export issued while MySQL was restarting reported could not start mysql: start lerd-mysql timed out after 30s even though the server was accepting a second after its turn arrived.
TIP
Raise this only for services that flush on shutdown. A longer window on an image that hangs rather than exits just makes every stop wait it out.
Pinning services
By default, lerd can auto-stop services that no active site references, either in its .lerd.yaml or in the env file its framework declares (.env, wp-config.php, app/etc/env.php, whichever the definition names). 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 references it, in its
.lerd.yamlor in the env file its framework declares - 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:
A depends_on entry is satisfied by that service, or by any installed service whose family or env_role names it (MariaDB for mysql, Valkey for redis, postgres-pgvector for postgres). Start resolves to one satisfier (literal name, then same-family, then env_role drop-in) and brings that one up. Stop cascade-stops a dependent only when nothing else still running meets its dependency; installed-but-stopped engines do not keep the admin UI alive. Otherwise family consumers are regenerated instead (after a just-started engine is ready, so host lists are not rewritten empty). Install preflight uses the same satisfaction rule and names the accepted alternatives when nothing is installed. discover_family lists only running family members in admin UIs, a pinned lerd-<dep> host wires single-host admin UIs to the active satisfier, and a bulk lerd start refreshes those lists once engines are up.
| Action | Effect |
|---|---|
lerd service start phpmyadmin (also UI / TUI / MCP) | Starts a satisfier for mysql first (MySQL itself, or MariaDB), then starts phpmyadmin |
lerd service start mysql (also UI / TUI / MCP) | Starts mysql, then also starts any services that depend on it (e.g. phpmyadmin) |
lerd service stop mysql (also UI / TUI / MCP) | Stops phpmyadmin first when nothing else running satisfies it, 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) |