Skip to content

devlanes is in beta and not ready for production use.Report an issue

Infra

Databases, brokers and object stores run as containers under infra, started before setup, tasks and services and waited on until healthy. A process reaches a container through its host port: ${{ infra.postgres.port }}.

Two shapes, and most projects mix them: one container shared by every lane with a namespace per lane inside it (a database, a vhost, a Redis index, a bucket), or one container per lane. Shared is cheaper and keeps one copy of the data; per lane isolates versions and lets a lane be wiped by lane clean.

devlanes talks to the Docker Engine the docker CLI would use (DOCKER_HOST, then the current context) and runs docker compose. Per-lane Compose services need Compose 2.24.4 or newer for !override and !reset. devlanes doctor shows both versions.

infra:
compose: docker-compose.yml # or [base.yml, dev.yml], merged like repeated -f
project_name: acme # fixed name, so docker compose -p acme works outside devlanes
exclude: [caddy] # never started; the devlanes proxy replaces local reverse proxies
per_lane: [mailpit] # one copy per lane
profiles: [development] # Compose profiles to enable
env: # Compose interpolation only
ACME_DB_PORT: 15432
hidden: [postgres] # not mirrored to machines connected with remote connect

The shared project runs from the primary worktree with that worktree’s .env as --env-file, so Compose interpolation (${ACME_DB_PORT:-15432}) sees your .env plus infra.env. Processes do not see infra.env; use global_env for them.

Published ports of shared services are taken as the file declares them. Two forms work well:

services:
postgres:
image: postgres:16
ports: ["127.0.0.1:${ACME_DB_PORT:-15432}:5432"] # fixed, known outside devlanes too
redis:
image: redis:7
ports: ["127.0.0.1::6379"] # ephemeral, Docker picks; devlanes reads it back

Both are read back from docker compose ps; infra.redis.port is the first published port and infra.postgres.ports['5432'] picks by container port. Give every container a healthcheck; up --wait returns when they pass, and a service that connects before Postgres accepts connections fails for nothing.

A shared service that depends_on an excluded or per-lane service is refused with the pair named. If containers with fixed container_names already exist from a manual docker compose up, the same project_name adopts them; otherwise the message says which project owns them.

per_lane services run in their own Compose project, <project>-lane<N>, from the lane’s worktree. devlanes writes an override file to compose/ in the lane data folder, outside the repository, that resets container_name, replaces ports with host ports from the lane’s block (every container port the service declares gets one, published or not) and adds dev.devlanes.* labels. Named volumes are per project, so each lane has its own data; a volume with a fixed name: or external: true is shared and gets a warning. Shared dependencies are not started again (--no-deps); reach them by published port.

In setup, docker compose without flags targets the shared project. The lane project’s name is $DEVLANES_COMPOSE_LANE_PROJECT:

infra:
per_lane: [postgres]
setup:
schema: docker compose -p "$DEVLANES_COMPOSE_LANE_PROJECT" exec -T postgres psql -U postgres -d app -c "CREATE TABLE IF NOT EXISTS notes (body text)"
infra:
containers:
cerbos:
image: ghcr.io/cerbos/cerbos:0.40.0
command: [server, --config=/policies/config.yaml]
volumes: ["./infra/cerbos:/policies:ro"] # relative to the worktree
ports: # container ports by name; host side allocated
http: 3592
grpc: 3593
per_lane: true
ready:
http:
path: /_cerbos/health
timeout: 45s
env:
CERBOS_LOG_LEVEL: debug

Containers are named devlanes-<project>-<name> or devlanes-<project>-lane<N>-<name>, bound to 127.0.0.1, and read as ${{ infra.cerbos.ports.http }} (infra.cerbos.port is the first port). A bare volume name becomes a named volume per project or lane; /abs and ./rel are bind mounts. For a per-lane bind mount that lanes of one folder do not share, use "${{ lane.data_path }}/<name>:/data"; devlanes creates the lane data folder before containers start. A running container whose image, command, env, mounts and ports are unchanged is adopted; a changed one is recreated. Readiness is the image’s healthcheck, else TCP on the first port, then ready if set. Plain containers join Docker’s default network, not a Compose network, so address them by host port, not container name.

A server devlanes does not run, such as a pgAdmin you start yourself or a docs server started with devlanes exec, joins the lanes under infra.external:

infra:
external:
pgadmin:
address: 127.0.0.1:5050 # 127.0.0.1:<port> or [::1]:<port>, nothing else
hosts: "pgadmin.${{ lane.domain }}"
docs:
address: "[::1]:8000"
hidden: true # never mirrored to connected machines

Every lane of the project lists each entry as a unit of kind and state external, with the same port and its own hostnames. hosts takes a hostname or a mapping with host and optional fallback and host_header, or a list of them, evaluated for each lane with lane, project, infra and env; keep the names under ${{ lane.domain }}, since a fixed name is claimed by every lane and conflicts like a service’s. A hostname that reads infra.<container> is routed once the lane’s infra runs. The proxy routes the hostnames to exactly the declared address, whether or not anything listens there. devlanes never starts, stops or checks the server, so start, stop, restart, logs and attach refuse the entry, and exec refuses its name; devlanes url print pgadmin prints its URL.

The port belongs to the project, not to a lane, and no unit ever gets it: new port blocks of every project skip it, and in a block that covers it the port goes to no new name. A port already in use is a config error naming the entry and what holds it, for the lane that declares the entry: the proxy’s HTTPS and HTTP ports and the DNS server’s, a port of this lane or of any other lane on the machine (other projects included), such as a service in a block from before the entry existed, a literal PORT or a host port a Compose file publishes as written. Ports outside ports.range (20000-32767 by default) never collide with allocated ones; literal and Compose ports can still collide anywhere. Two entries cannot share a port, and an entry’s name cannot be a service’s, task’s or container’s, or setup.<entry> or teardown.<entry> of an infra command.

Machines connected with devlanes remote connect mirror the port and hostnames while a lane of the project runs a service, task or exec command, or has its containers up (remote). Declare an entry in devlanes.yaml when the team runs that server, or in devlanes.local.yaml when only you do.

lanes:
vars:
db_name: acme_${{ lane.name }}
infra:
setup:
database: docker compose exec -T postgres createdb -U postgres ${{ lane.db_name }}
vhost:
run: docker compose exec -T rabbitmq rabbitmqctl add_vhost ${{ lane.vhost }}
container: rabbitmq # run again when this container restarts (its data may be gone)
ignore_errors: true # "already exists" is fine
timeout: 60s
teardown:
database: docker compose exec -T postgres dropdb -U postgres --if-exists --force ${{ lane.db_name }}

setup entries run once per lane, after its containers are healthy, and are recorded as done; later starts skip them. They run concurrently, so an entry must not depend on another one. A failure (without ignore_errors) stops the start and names the entry. teardown runs on devlanes lane clean, in order, and never on stop. Tasks can reuse entries as steps: steps: [infra.teardown.database, infra.setup.database, migrate, seed]. devlanes run infra.setup.database runs one entry again on its own and records it as done; s on the entry in the TUI does the same.

Entries take only run, written out in full (docker compose exec ..., curl ...), so the command in the config is the command that runs. A script from package.json or a justfile belongs in a task, which can call setup entries as steps.

Creating the namespace is setup (once); migrating it is a task with when: lane.start (every start, idempotent by the migration tool). Seeding is a task with when: lane.create and after: services when it goes through the API.

  • devlanes start brings infra up for the lane. Shared containers stay up while any lane of the project runs.
  • devlanes stop stops processes only; containers keep running.
  • devlanes infra down stops the shared containers (data kept); per-lane containers are untouched. infra up and infra status are the counterparts.
  • devlanes start and devlanes infra up leave a Compose service alone when its containers already run healthy with the config Compose resolves now. docker compose pull does not change that config, so a running container keeps its old image after a pull. To switch it to the pulled image, remove the container and start again: docker compose -p acme rm -sf postgres for a shared service (the project is project_name, or Compose’s default name when that is unset), docker compose -p acme-lane2 rm -sf mailpit for a per-lane service of lane 2, then devlanes start or devlanes infra up. These commands need only the project name, so they work from any directory. Named volumes keep their data; data the image keeps in an anonymous volume does not carry over to the new container.
  • A frozen lane’s per-lane containers are paused until the lane thaws (idle lanes). When the daemon starts, it unpauses every lane’s own paused containers, including ones paused by hand with docker pause.
  • devlanes lane clean <lane> --yes runs lane.clean tasks and teardown, then removes the lane’s Compose project with its volumes and the lane’s plain containers and named volumes. Shared containers and their data survive.
  • A profile with infra: false skips containers and setup; services that reference infra.* then fail to resolve, so keep those references out of the profile’s services or point them elsewhere in the profile’s env.

Each recipe shows the Compose service, the devlanes side, and how a process gets the address. lane.name is main for lane 0 and lane<N> otherwise.

docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
ports: ["127.0.0.1::5432"]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
retries: 30
volumes:
pgdata:
devlanes.yaml
lanes:
vars:
db_name: "acme_${{ lane.name }}"
infra:
compose: docker-compose.yml
setup:
database: docker compose exec -T postgres createdb -U postgres ${{ lane.db_name }}
teardown:
database: docker compose exec -T postgres dropdb -U postgres --if-exists --force ${{ lane.db_name }}
global_env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:${{ infra.postgres.port }}/${{ lane.db_name }}
tasks:
migrate:
run_script:
file: package.json
name: db:migrate
when: lane.start

Extensions or roles the app expects go into the same setup entry (psql -c "CREATE EXTENSION ...") or into the migration.

For version pinning per branch, or when migrations are destructive:

infra:
compose: docker-compose.yml
per_lane: [postgres]
setup:
schema: docker compose -p "$DEVLANES_COMPOSE_LANE_PROJECT" exec -T postgres psql -U postgres -c "CREATE DATABASE app"
global_env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:${{ infra.postgres.port }}/app

Each lane’s Postgres gets a port from the lane’s block and its own pgdata volume. lane clean deletes it.

docker-compose.yml
services:
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: root
ports: ["127.0.0.1::3306"]
volumes: [mysqldata:/var/lib/mysql]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"]
interval: 2s
retries: 30
infra:
setup:
database: docker compose exec -T mysql mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS ${{ lane.db_name }}"
teardown:
database: docker compose exec -T mysql mysql -uroot -proot -e "DROP DATABASE IF EXISTS ${{ lane.db_name }}"
global_env:
DATABASE_URL: mysql://root:root@127.0.0.1:${{ infra.mysql.port }}/${{ lane.db_name }}
lanes:
vars:
redis_db: "${{ lane.index }}"
infra:
compose: docker-compose.yml
teardown:
redis: docker compose exec -T redis redis-cli -n ${{ lane.redis_db }} FLUSHDB
global_env:
REDIS_URL: redis://127.0.0.1:${{ infra.redis.port }}/${{ lane.redis_db }}

Redis has 16 databases by default; raise databases in its config for more lanes, or put Redis in per_lane instead. Libraries that use SELECT poorly (some BullMQ setups) prefer a key prefix: BULL_PREFIX: acme_${{ lane.name }}.

lanes:
vars:
vhost: "${{ 'acme' if lane.index == 0 else 'acme_' ~ lane.name }}"
infra:
setup:
vhost:
run: |
docker compose exec -T rabbitmq rabbitmqctl add_vhost ${{ lane.vhost }} || true
docker compose exec -T rabbitmq rabbitmqctl set_permissions -p ${{ lane.vhost }} acme '.*' '.*' '.*'
container: rabbitmq
teardown:
vhost:
run: "docker compose exec -T rabbitmq rabbitmqctl delete_vhost ${{ lane.vhost }}"
ignore_errors: true
global_env:
AMQP_URL: amqp://acme:acme@127.0.0.1:${{ infra.rabbitmq.port }}/${{ lane.vhost }}

container: rabbitmq reruns the entry when the container was recreated, since vhosts live in its volume. Route the management UI through the proxy: infra.hosts: { rabbitmq: "rabbit.${{ lane.domain }}" } with the 15672 port published first, or with port: "15672".

lanes:
vars:
bucket: "media-${{ lane.name }}"
infra:
hosts:
minio: "s3.${{ lane.domain }}" # presigned URLs the browser can open
setup:
bucket:
run: [aws, --endpoint-url, "http://127.0.0.1:${{ infra.minio.port }}", s3, mb, "s3://${{ lane.bucket }}"]
ignore_errors: true
teardown:
bucket:
run: [aws, --endpoint-url, "http://127.0.0.1:${{ infra.minio.port }}", s3, rb, "s3://${{ lane.bucket }}", --force]
ignore_errors: true
global_env:
S3_ENDPOINT: http://127.0.0.1:${{ infra.minio.port }}
S3_PUBLIC_ENDPOINT: https://s3.${{ lane.domain }}
S3_BUCKET: ${{ lane.bucket }}

The aws or mc CLI must be on the PATH of the shell that runs devlanes start. infra.hosts works for any container with an HTTP port: MinIO’s console, Mailpit, pgweb, a mock OIDC provider.

docker-compose.yml
services:
mailpit:
image: axllent/mailpit
ports: ["127.0.0.1::8025", "127.0.0.1::1025"]
infra:
per_lane: [mailpit]
hosts:
mailpit: "mail.${{ lane.domain }}" # first published port, the web UI
global_env:
SMTP_HOST: 127.0.0.1
SMTP_PORT: ${{ infra.mailpit.ports['1025'] }}

Each lane’s outgoing mail lands in its own inbox at https://mail.acme2.localhost.

infra:
containers:
oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
http: 8080
per_lane: true
env:
JSON_CONFIG_PATH: /config.json
volumes: ["./infra/oidc.json:/config.json:ro"]
hosts:
oidc: "auth.${{ lane.domain }}"
global_env:
OIDC_ISSUER: https://auth.${{ lane.domain }}/default

Anything with an image and a port fits this shape when a Compose file would be overkill.

Terminal window
devlanes infra status # containers, health and ports
devlanes infra up # start infra without the services
devlanes infra logs db -f # a container's Docker logs, also across restarts
devlanes env show --explain # what infra.* resolved to, through global_env
docker compose -p acme ps # the shared project by name
docker ps --filter label=dev.devlanes.project=acme

In the TUI, selecting a container under infra shows the same Docker logs in the pane, with the scrollback and copy keys a service has. devlanes does not store them; older lines stay with Docker.