Skip to content

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):

bash
lerd service add mongodb.yaml

With flags (quick one-off):

bash
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)

bash
lerd service remove mongodb            # stops + removes; data preserved
lerd service remove mongodb --purge    # also wipes data dir

lerd 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

bash
lerd service reinstall postgres                # same version, data preserved
lerd service reinstall postgres --reset-data   # same version, fresh data

reinstall 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.yaml db.database, then .env DB_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 .env AWS_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.

Tuning a service

bash
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-restart

lerd 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:

yaml
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 file

The 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

yaml
# 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. 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

# 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. 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')"

Bundled admin dashboards embed in place

The bundled RabbitMQ and RedisInsight presets carry dashboard_external, but lerd does not send you to a new tab for them. lerd-ui proxies 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. The new-tab behavior above applies only to your own custom services.

Site handle placeholders

env_vars values and site_init.exec support two placeholders that are substituted per-project when lerd env runs:

PlaceholderExpands 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:

  1. env_vars are written into .env, with {{site}} and {{site_testing}} substituted
  2. The service is started if not already running
  3. site_init.exec is 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:

bash
lerd service pin mysql    # always keep MySQL running
lerd service pin redis

Pinning a service also starts it immediately if it is not already running. Unpin to restore normal auto-stop behaviour:

bash
lerd service unpin mysql

Pinned 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 .env references 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:

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:

bash
lerd service add \
  --name phpmyadmin \
  --image docker.io/phpmyadmin:latest \
  --port 8080:80 \
  --depends-on mysql \
  --dashboard http://localhost:8080

Behaviour:

ActionEffect
lerd service start phpmyadminStarts mysql first (if not already running), then starts phpmyadmin
lerd service start mysqlStarts mysql, then also starts any services that depend on it (e.g. phpmyadmin)
lerd service stop mysqlStops 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:

yaml
depends_on:
  - mysql
  - redis

Dependencies 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.

yaml
# ~/.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:9601
bash
lerd service add ~/.config/lerd/services/soketi.yaml
lerd service start soketi

Soketi 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.

yaml
# ~/.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:12111
bash
lerd service add ~/.config/lerd/services/stripe-mock.yaml
lerd service start stripe-mock

Point the Stripe PHP SDK at the mock in your AppServiceProvider or test bootstrap:

php
\Stripe\Stripe::$apiBase = 'http://lerd-stripe-mock:12111';

Flag reference

FlagDescription
--nameService name, slug format [a-z0-9-] (required)
--imageOCI image reference (required)
--portPort mapping host:container (repeatable)
--envContainer environment variable KEY=VALUE (repeatable)
--env-var.env variable injected by lerd env, supports {{site}} (repeatable)
--data-dirMount path inside the container for persistent data
--detect-key.env key that triggers auto-detection in lerd env
--detect-prefixOptional value prefix filter for auto-detection
--init-execShell command run inside the container once per site (supports {{site}} and {{site_testing}})
--init-containerContainer to run --init-exec in (default: lerd-<name>)
--dashboardURL to open when clicking the dashboard button in the web UI
--descriptionDescription shown in lerd service list
--depends-onService name that must be running before this one (repeatable: --depends-on mysql --depends-on redis)

Released under the MIT License.