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.
Compose
Section titled “Compose”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 connectThe 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 backBoth 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 Compose services
Section titled “Per-lane Compose services”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)"Plain containers
Section titled “Plain containers”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: debugContainers 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.
External ports
Section titled “External ports”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 machinesEvery 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.
Setup and teardown
Section titled “Setup and teardown”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.
Lifecycle
Section titled “Lifecycle”devlanes startbrings infra up for the lane. Shared containers stay up while any lane of the project runs.devlanes stopstops processes only; containers keep running.devlanes infra downstops the shared containers (data kept); per-lane containers are untouched.infra upandinfra statusare the counterparts.devlanes startanddevlanes infra upleave a Compose service alone when its containers already run healthy with the config Compose resolves now.docker compose pulldoes 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 postgresfor a shared service (the project isproject_name, or Compose’s default name when that is unset),docker compose -p acme-lane2 rm -sf mailpitfor a per-lane service of lane 2, thendevlanes startordevlanes 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> --yesrunslane.cleantasks andteardown, 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: falseskips containers and setup; services that referenceinfra.*then fail to resolve, so keep those references out of the profile’s services or point them elsewhere in the profile’senv.
Recipes
Section titled “Recipes”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.
Postgres, shared, one database per lane
Section titled “Postgres, shared, one database per lane”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: 30volumes: pgdata: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.startExtensions or roles the app expects go into the same setup entry (psql -c "CREATE EXTENSION ...") or into the migration.
Postgres, one container per lane
Section titled “Postgres, one container per lane”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 }}/appEach lane’s Postgres gets a port from the lane’s block and its own pgdata volume. lane clean deletes it.
MySQL and MariaDB
Section titled “MySQL and MariaDB”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: 30infra: 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 }}Redis, one database index per lane
Section titled “Redis, one database index per lane”lanes: vars: redis_db: "${{ lane.index }}"infra: compose: docker-compose.yml teardown: redis: docker compose exec -T redis redis-cli -n ${{ lane.redis_db }} FLUSHDBglobal_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 }}.
RabbitMQ, one vhost per lane
Section titled “RabbitMQ, one vhost per lane”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: trueglobal_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".
MinIO or another S3, one bucket per lane
Section titled “MinIO or another S3, one bucket per lane”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: trueglobal_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.
Mailpit, one per lane
Section titled “Mailpit, one per lane”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 UIglobal_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.
A plain container per lane
Section titled “A plain container per lane”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 }}/defaultAnything with an image and a port fits this shape when a Compose file would be overkill.
Checking
Section titled “Checking”devlanes infra status # containers, health and portsdevlanes infra up # start infra without the servicesdevlanes infra logs db -f # a container's Docker logs, also across restartsdevlanes env show --explain # what infra.* resolved to, through global_envdocker compose -p acme ps # the shared project by namedocker ps --filter label=dev.devlanes.project=acmeIn 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.