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 start/stop, lerd env and the dashboard, without recompiling. Related: Service presets for ready-made installers, Services for the built-in list.

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

A removed service stays removed. A site whose .lerd.yaml still lists it does not bring it back: lerd install, lerd update, lerd link and the commands that start a project's services all skip it, and the site's service card offers an install (+) action instead. Installing it again on purpose (lerd service preset, lerd service add, lerd service start, or that + action) lifts this.

If a service container is running that no installed service owns, for example one an older lerd restored from a site's .lerd.yaml without its definition, the dashboard's Resources card marks it orphaned and offers a trash button, which stops it and deletes its unit while keeping its data.

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.

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 ​

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, 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.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 over the S3 API. 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.

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 ​

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

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`
label: "MongoDB"                       # the name the dashboard shows; omit it and the name is
                                       # capitalized (mongo-express -> Mongo Express)

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

A third field covers the upstream that cannot be told a mount path at all. Solr serves its UI only under /solr and the Mercure hub's only under /.well-known, and neither has a setting for it, so dashboard_proxy_strip has the proxy remove its own prefix before forwarding instead. The dashboard URL's path carries what is left, which works wherever that UI's links are relative, and redirects naming the upstream's base path are rewritten back inside the mount. A lerd that predates the field ignores it, and the dashboard stays where it was.

Stripping alone is not enough for a page whose own links start at the origin root, since on the shared lerd-ui origin that root is lerd's rather than the upstream's. While the prefix is stripped, href and src values beginning with a single slash are rewritten onto the mount on their way through, and dashboard_proxy_rebase names any further attribute that carries a path the same way, for a page that hands its own router a base path rather than a link. Mailpit's data-webroot is one, which is how the mail view is embedded without telling Mailpit to move, leaving localhost:8025 answering as it always has.

A URL a script computes at call time is past rebasing, since it does not exist in the page. dashboard_proxy_reroute covers that: an inline script wraps fetch and XMLHttpRequest before the app's own scripts run, so a root-absolute request the app builds from the origin it was served at goes through the mount instead of lerd's root. Meilisearch's mini-dashboard asks window.location.origin for its API host, which is what needs it. Requests already inside the mount, other origins and relative URLs are untouched.

Some dashboards cannot be moved at all. RustFS's console is a Next build rooted at /rustfs/console, with that path compiled into its own routing, so dashboard_proxy_at_path serves it there instead of under the mount, and the /_svc/<name>/ mount stays for what the page asks of the upstream's root. The lerd vhost names the path alongside its own, so nothing has to be registered by hand. Such a dashboard usually signs its requests, and a signature covers the Host header, so dashboard_proxy_keep_host forwards the Host the browser sent rather than the upstream's, or every call the page makes is rejected.

A dashboard that remembers its own language in a cookie, the way phpMyAdmin keeps pma_lang, would hold on to it after a language is picked in lerd, since it reads that cookie ahead of the Accept-Language the proxy sends. dashboard_locale_cookies names those cookies, and the proxy leaves them out of each request while lerd has a language picked.

An embedded dashboard wears the theme lerd is wearing rather than the one the browser is set to. Most of them ship their dark half behind a prefers-color-scheme query, and the overlay flips that query where it finds it, on the link or inside the sheet. An app that reads the preference from JavaScript instead is out of reach of that, so dashboard_follows_color_scheme has lerd answer the question directly, which is how pgAdmin gets its own dark design: it ships its theme preference set to system and asks the browser which scheme it is in.

A dashboard with no dark design at all, phpMyAdmin being the case in point, is turned around instead: lerd reads the greys off its own stylesheet and maps them onto the palette, so what the design used for its page becomes the page and what it used for text becomes the tone text reads in. A colour carrying a hue keeps it and is only moved far enough to be read, and every pair is held to the WCAG AA floor.

A preset can also describe its own login form, which lerd then fills with the credentials it provisioned the service with:

yaml
dashboard_login:
  path: /rustfs/console/auth/login/   # only act here
  fields:
    "#accessKey": RUSTFS_ACCESS_KEY   # selector: the env var holding the value
    "#secretKey": RUSTFS_SECRET_KEY
  submit: form button[type="submit"]
  done: auth.credentials              # what the app stores once it is in
  expires: Expiration                 # the field in it saying when that runs out

The values are written through the input's native setter and announced as a keystroke would be, so a form that tracks its fields in JavaScript sees them. done is read before anything is typed, so a dashboard already logged in is left alone. A dashboard whose session runs out leaves that key behind when it goes stale, which would read as still being logged in, so expires names the field inside the stored value holding the moment it stops counting.

A dashboard whose content security policy keeps inline scripts out still runs the ones lerd injects: they carry the page's own script nonce, or, on a page with none, the policy is extended with their hashes.

A dashboard that cannot be proxied has no form lerd can reach, since its page is on another origin. If it hands out a session token through its own API and has a page that takes one, dashboard_token_login describes that instead:

yaml
dashboard_token_login:
  request: /api/auth/login            # POSTed as JSON on the dashboard's origin
  body:
    username: UMAMI_USERNAME          # body field: the env var holding the value
    password: UMAMI_PASSWORD
  token: token                        # the response field carrying the token
  open: /sso?token={token}&url={path}

The dashboard is then opened through /_svc/<service>/, where lerd-ui logs in and redirects to open, with {token} and {path} (the page asked for under the mount) query-escaped. Each credential is read from the container's environment first and the env_vars second, so a login the app ships with, rather than one lerd sets, still counts. If a credential is missing or the login fails, the dashboard opens on its own login page.

Every database engine also gets the database its connection_url names when a service that depends on it starts. The image only creates it while initialising a fresh data directory, so a directory that predates it would otherwise leave the dependent crash-looping.

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

yaml
stop_timeout: 60

The 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, whether in its .lerd.yaml, in the env file its framework declares (.env, wp-config.php, app/etc/env.php, whichever the definition names), or in what lerd env last wired into it. 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. Anything else that starts it on purpose ends the pause too: lerd env or lerd link on a site that needs it, a lerd db command against it, or a service that depends on it. Once it is running again it is no longer paused, so the next lerd start brings it back like any other.

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.yaml, in the env file its framework declares, or in what lerd env last wired into 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/library/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/library/phpmyadmin:latest \
  --port 8080:80 \
  --depends-on mysql \
  --dashboard http://localhost:8080

Behaviour:

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.

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

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.