Skip to content

Containers walkthrough

End-to-end: from an empty Node, Python, or Go project to an HTTPS site running at https://myapp.test with services, workers, and automatic rebuilds on Containerfile changes.

Prerequisites

You've already run lerd install once on this machine. If not, see Installation.

When to use this

Use a custom container when your project isn't PHP, or when a PHP project needs a non-standard runtime (alternate PHP build, FrankenPHP, RoadRunner). PHP projects that fit the built-in PHP-FPM image should use the Laravel, Symfony, or WordPress walkthroughs instead.


1. Add a Containerfile.lerd

Drop a Containerfile.lerd at the project root. Lerd bind-mounts the project directory into the container at the same absolute path at runtime, so you don't need WORKDIR or COPY. Only install tooling (global CLIs, system packages, language runtimes).

dockerfile
FROM node:20-alpine
RUN apk add --no-cache git
RUN npm install -g nodemon pnpm
CMD ["npm", "run", "start:dev"]
dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uvicorn[standard] watchfiles
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
dockerfile
FROM golang:1.23-alpine
RUN go install github.com/air-verse/air@latest
CMD ["air"]
dockerfile
FROM ruby:3.3-slim
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev nodejs \
    && rm -rf /var/lib/apt/lists/*
CMD ["bin/rails", "server", "-b", "0.0.0.0"]

Why bind mount, not COPY?

Lerd is a dev environment: source lives on your host, edits are live. Baking source into the image would force a rebuild on every save. Your production Dockerfile still uses COPY and multi-stage builds, just name it something other than Containerfile.lerd.


2. Run lerd init

bash
cd ~/projects/myapp
lerd init

When no PHP project is detected and a Containerfile.lerd exists, the wizard switches to custom container mode:

? Container port: 3000
? Containerfile: Containerfile.lerd
? Enable HTTPS? Yes
? Services: [mysql, redis]
Saved .lerd.yaml

The wizard writes .lerd.yaml:

yaml
domains:
  - myapp
container:
  port: 3000
secured: true
services:
  - mysql
  - redis

Port matters

container.port is the port your app listens on inside the container. Nginx will proxy_pass to that port on the container's internal network address. You don't publish it on the host.


bash
lerd link

lerd link:

  1. Builds the image, tagged lerd-custom-myapp:local (Containerfile hash is cached, so unchanged files skip rebuild)
  2. Writes a systemd quadlet so the container starts on boot
  3. Joins the container to the shared lerd network
  4. Generates an nginx vhost that reverse-proxies myapp.test to the container
  5. Reloads nginx

Order matters

lerd link must run after both Containerfile.lerd and .lerd.yaml exist. If you ran lerd link before writing .lerd.yaml, Lerd registered the project as a PHP site. Run lerd unlink, then lerd init, then lerd link again.


4. Reach your services

Services on the lerd network are reachable by hostname. Wire them into your app's env file:

bash
DATABASE_URL=mysql://root:lerd@lerd-mysql:3306/myapp
REDIS_URL=redis://lerd-redis:6379
MAIL_HOST=lerd-mailpit
MAIL_PORT=1025
bash
DATABASE_URL=postgresql://postgres:lerd@lerd-postgres:5432/myapp
REDIS_URL=redis://lerd-redis:6379/0
bash
DATABASE_DSN=postgres://postgres:lerd@lerd-postgres:5432/myapp?sslmode=disable
REDIS_ADDR=lerd-redis:6379
ServiceHostDefault portDefault password
MySQLlerd-mysql3306lerd (user root)
PostgreSQLlerd-postgres5432lerd (user postgres)
Redislerd-redis6379(none)
Meilisearchlerd-meilisearch7700(none)
RustFS (S3)lerd-rustfs9000lerd / lerdpassword
Mailpit (SMTP)lerd-mailpit1025(none)

See Services for the full credential matrix, including host-tool ports (127.0.0.1) versus container-network hostnames.

Create the database:

bash
lerd db:create myapp

See Database for imports, shells, and switching engines.


5. Add workers

Long-running processes (dev server, queue consumer, scheduler) live under custom_workers in .lerd.yaml. Each worker runs via podman exec inside the same container as your app.

yaml
container:
  port: 3000
custom_workers:
  dev:
    label: Dev Server
    command: npm run start:dev
    restart: always
  queue:
    label: Queue Worker
    command: node dist/jobs/worker.js
    restart: on-failure
  cron:
    label: Nightly Cleanup
    command: node dist/jobs/cleanup.js
    schedule: daily

Start and stop them like any other worker:

bash
lerd worker list
lerd worker start dev
lerd worker start queue
lerd worker stop queue

Workers appear in the Web UI with live logs. For schedule: timers see Queue Workers.


6. Hot reload (polling)

The project directory is bind-mounted, but inotify events don't cross the Podman Machine boundary on macOS, and can be unreliable on Linux with virtiofs. File watchers that rely on inotify need polling:

ToolConfig
nodemonnodemon --legacy-watch src/main.js
Viteserver.watch.usePolling: true in vite.config
Next.jsWATCHPACK_POLLING=true env var
NestJSnodemon.json: {"legacyWatch": true}
webpackwatchOptions: { poll: 1000 }
uvicorn--reload --reload-delay 0.5 (uses watchfiles, already polls)
Djangorunserver polls by default
air (Go)polls by default
Railsconfig.file_watcher = ActiveSupport::FileUpdateChecker + rerun gem

Poll interval around 1 second is usually fine for development.


7. HTTPS

bash
lerd secure

lerd secure issues an mkcert certificate for myapp.test, flips the nginx vhost to TLS, and regenerates the proxy config. Your app keeps receiving plain HTTP from nginx, which handles TLS termination.

If your app serves its own HTTPS (FrankenPHP with built-in TLS, a Go service with Let's Encrypt test certs), add ssl: true so nginx proxies via HTTPS with verification disabled:

yaml
container:
  port: 3000
  ssl: true

See HTTPS / TLS for wildcard certs and git worktree support.


8. Verify

bash
lerd status

You should see myapp as active, the container as running, services healthy, and any started workers listed. Live logs for the container and workers live in the Web UI at http://127.0.0.1:7073.

Open the site:

bash
lerd open

Common stacks

yaml
domains: [myapp]
container:
  port: 3000
secured: true
services:
  - mysql
  - redis
custom_workers:
  dev:
    label: Nest Dev
    command: npm run start:dev
    restart: always
  queue:
    label: BullMQ Worker
    command: node dist/queue/worker.js
    restart: on-failure
yaml
domains: [shop]
container:
  port: 3000
secured: true
services:
  - postgres
  - redis
custom_workers:
  dev:
    label: Next Dev
    command: npm run dev
    restart: always
yaml
domains: [api]
container:
  port: 8000
secured: true
services:
  - postgres
  - redis
custom_workers:
  dev:
    label: Uvicorn
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    restart: always
  celery:
    label: Celery Worker
    command: celery -A app.worker worker --loglevel=info
    restart: on-failure
yaml
domains: [api]
container:
  port: 8080
secured: true
services:
  - postgres
custom_workers:
  dev:
    label: Air
    command: air
    restart: always
yaml
domains: [shop]
container:
  port: 3000
secured: true
services:
  - postgres
  - redis
custom_workers:
  web:
    label: Puma
    command: bin/rails server -b 0.0.0.0
    restart: always
  sidekiq:
    label: Sidekiq
    command: bundle exec sidekiq
    restart: on-failure

What just happened

CommandWhat it did
lerd initDetected Containerfile.lerd, ran the container wizard, wrote .lerd.yaml with container:, services, and workers
lerd linkBuilt lerd-custom-myapp:local, wrote the quadlet, started the container on the lerd network, generated an nginx proxy vhost, reloaded nginx
lerd db:create myappCreated the myapp database in the selected engine
lerd secureIssued a mkcert cert, flipped the vhost to HTTPS
lerd worker start devStarted lerd-dev-myapp.service which podman execs into the container

Rebuilding after Containerfile changes

lerd link reuses the cached image (Containerfile MD5 hash). When you change Containerfile.lerd, rebuild explicitly:

bash
lerd rebuild

This removes the old image, rebuilds from the current Containerfile, and restarts the container. No downtime for nginx or services.

lerd restart restarts the container without rebuilding, useful after changing a mounted config file that the app reads on startup.


Next steps

Released under the MIT License.