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.

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

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.