Service patterns
Configurations for the shapes most repositories have. Each one assumes the basics from getting started: a service gets PORT, a lane gets lane.domain, hostnames go through the HTTPS proxy.
Frontend and API
Section titled “Frontend and API”services: api: run_script: file: apps/api/package.json name: dev hosts: "api.${{ lane.domain }}" ready: http: path: /healthz env: COOKIE_DOMAIN: ${{ lane.domain }} # the app reads DEVLANES_ORIGINS for its CORS allow-list web: preset: vite run_script: file: apps/web/package.json name: dev hosts: "${{ lane.domain }}" env: VITE_API_URL: "${{ services.api.public_url }}"The browser talks to https://acme2.localhost and https://api.acme2.localhost; both share the site acme2.localhost, so a cookie with Domain=acme2.localhost and SameSite=Lax works across them and never leaks into lane 3. DEVLANES_ORIGINS is in every service’s environment and lists the https origins of the lane, so the API’s CORS allow-list can be process.env.DEVLANES_ORIGINS.split(',') instead of a hardcoded list. It is not reachable as ${{ env.DEVLANES_ORIGINS }}, since env.* reads your shell, not devlanes’ own variables.
For server-to-server calls prefer ${{ services.api.url }} (http://127.0.0.1:<port>, no TLS, no proxy hop). Use public_url when the value ends up in a browser, an OAuth redirect or a webhook.
The http check on api passes on any 2xx answer; with status set, only that code passes. Redirects are not followed, so a route that redirects to a login needs status: 302. The check sends Host: 127.0.0.1:<port>; an app that serves its health route only on its own hostname needs host: ${{ self.hosts[0] }} under http. Every check request sends User-Agent: devlanes-ready; match that header to keep the checks, retried every 150 ms until they pass, out of request logs, traces or metrics.
A frontend that proxies /api to the backend itself (Vite server.proxy, Next rewrites) points at ${{ services.api.url }} and needs no CORS at all:
web: env: API_PROXY_TARGET: "${{ services.api.url }}"Workers and consumers
Section titled “Workers and consumers”services: queue-worker: run_script: file: apps/worker/package.json name: dev ready: log: "consumer connected" pty: false stop: signal: SIGINT timeout: 30s tags: [worker]A worker has no port, so ready defaults to started, which reports a crashed-then-watching process as ready. Have it print one line when it is connected and wait for that line. pty: false gives it pipes; some loggers behave better without a terminal. stop sets the signal and grace period before the process tree is killed, for consumers that finish in-flight jobs on SIGINT. A worker waiting for jobs gets no requests, so its lane would freeze after lanes.freeze_after; keep_awake: true keeps the lane awake while the worker runs (idle lanes).
Cron-like processes and one-shot jobs are tasks, not services: a service that exits shows as failed.
WebSockets, SSE and long polling
Section titled “WebSockets, SSE and long polling”Nothing to configure. Upgrades are bridged with no idle timeout and streamed responses are not buffered. Browsers open WebSockets over HTTP/1.1 on a separate connection, which the proxy accepts.
gRPC and raw TCP
Section titled “gRPC and raw TCP”The proxy speaks HTTP/1.1 to upstreams, so gRPC over HTTP/2 does not go through a hostname. Point clients at the port:
services: grpc-api: run: cargo run --bin grpc-api # listens on PORT web-bff: env: GRPC_TARGET: "127.0.0.1:${{ services.grpc_api.port }}"The same goes for databases, debuggers and anything that is not HTTP. On a connected box these ports are mirrored under the same number (remote boxes).
Several ports, several hostnames
Section titled “Several ports, several hostnames”services: realtime: run: node server.mjs env: WS_PORT: ${{ self.ports.ws }} INSPECT: 127.0.0.1:${{ self.ports.inspect }} hosts: - rt.${{ lane.domain }} - host: "ws.${{ lane.domain }}" port: wshosts takes one hostname or route mapping, or a list of them: hosts: "api.${{ lane.domain }}" is the same as a one-item list. Each self.ports.<name> allocates one more port, stable per lane. Route hostnames to them with hosts[].port. The admin UI and the public API of one process can live on two hostnames this way, and devlanes url ls lists all of them.
Wildcard hostnames
Section titled “Wildcard hostnames”services: tenants: run_script: file: apps/tenants/package.json name: dev hosts: - app.${{ lane.domain }} - "*.${{ lane.domain }}" gateway: hosts: - host: "*.${{ lane.domain }}" fallback: true # only when nothing else matchedExact hostnames win over wildcards; a fallback wildcard loses to every other route. *.acme2.localhost covers foo.acme2.localhost and a.b.acme2.localhost, not acme2.localhost itself. For a product domain on a site of its own use a lane var: pages_domain: ${{ domain('acme-pages') }} and "*.${{ lane.pages_domain }}" (a site of its own).
Quote wildcard entries: an unquoted leading * is a YAML alias. /etc/hosts takes only exact names, so programs that cannot resolve *.localhost reach wildcard hostnames only through the resolver strategy, and only under its hostnames.tld; a lane var hardcoded to .localhost stays out of their reach. See Hostnames and HTTPS.
Replicas
Section titled “Replicas”services: worker: run: node worker.mjs replicas: 3 env: WORKER_ID: "${{ replica.index }}"Each replica is a unit worker.1, worker.2, worker.3 with its own PORT. services.worker.url is replica 1. Named ports are allocated per replica too.
Services that start on demand
Section titled “Services that start on demand”autostart: false is covered in lane lifecycle.
One lane’s frontend against another lane’s backend
Section titled “One lane’s frontend against another lane’s backend”profiles: attach: include: [+web] infra: false env: API_URL: ${{ lanes.main.services.api.url }} VITE_API_URL: ${{ lanes.main.services.api.public_url }}devlanes start --profile attach in a feature worktree runs only the frontend and points it at lane 0’s API, skipping containers and setup. lanes.main.services.* resolves against lane 0 of the same project; projects.<name>.services.* reaches another devlanes project that is running.
Lifecycle tasks
Section titled “Lifecycle tasks”Installs, migrations and seeds that run at lane events are covered in lane lifecycle.
Tags, profiles and templates
Section titled “Tags, profiles and templates”tags: api: color: cyantemplates: fastify-api: platform: node tags: [api] ready: http: path: /-/healthservices: catalog: extends: fastify-api run_script: file: apps/catalog/package.json name: dev hosts: "catalog.${{ lane.domain }}" tags: [api, essential] crm: extends: fastify-api run_script: file: apps/crm/package.json name: dev hosts: "crm.${{ lane.domain }}" tags: [api, essential]profiles: default: [+essential] backend: [+api, +worker] everything: include: ["*"] exclude: [+desktop] e2e: include: [+api, +web] run_scripts: dev: start env: NODE_ENV: productionA unit’s colour comes from its first tag with a color under tags, else its first category tag (api, web, worker, db), so put the category tag first and selection tags after. Profiles select by name, glob or +tag; with a default profile, a lane’s first plain devlanes start starts exactly that selection. --profile repeats and the union is started.
After that, a plain devlanes start brings back what the lane ran instead (bringing a lane back).
Monorepo with several apps
Section titled “Monorepo with several apps”One devlanes.yaml at the repository root, one service per app, run_script pointing at each package’s package.json. cwd is the script’s directory automatically. Shared libraries that need building before the apps start are a lane.start task (build-libs in lifecycle tasks) or a watcher service tagged watcher.
An app can also have its own devlanes.yaml in a subdirectory, which makes it a separate project with its own name, lanes, ports and domains. A command uses the nearest devlanes.yaml above the current directory, up to the repository root, so apps/billing/devlanes.yaml applies anywhere under apps/billing and the root file everywhere else. Once devlanes has run in a project, --project <name> (or -p) picks it instead of the current directory’s, as in devlanes -p billing status. In a subdirectory project, relative paths start at that subdirectory: cwd, the directory run starts in when cwd is not set, run_script.file, sync paths, the cwd of infra commands and infra.compose. lane.path is still the worktree root.
project: billingservices: api: run_script: file: package.json # apps/billing/package.json name: dev hosts: "${{ lane.domain }}" # billing.localhost, billing2.localhostA worktree can hold a lane of each project whose file it has, so one worktree of this repository can run a lane of acme and a lane of billing side by side.
Several projects in one directory
Section titled “Several projects in one directory”A second project file next to devlanes.yaml runs the same apps another way, such as a production build next to the dev servers. It extends devlanes.yaml and sets its own project:
extends: devlanes.yamlproject: acme-prodtasks: build: run: pnpm build when: lane.startprofiles: default: run_scripts: dev: start # services whose run_script names dev run start env: NODE_ENV: productiondevlanes only finds devlanes.yaml by itself. Name another file the first time with the global --file flag; after that --project finds it:
devlanes --file devlanes.prod.yaml startdevlanes -p acme-prod statusdevlanes -p acme-prod logs api --tail 50--project also reads DEVLANES_PROJECT when you export it. Every process a lane starts gets DEVLANES_LANE_PROJECT instead, which devlanes commands use when they run in one of that project’s lanes or outside every project, so a script run by an acme-prod task or service stays in acme-prod. The file may have any name except one ending in .local.yaml, and its local file is devlanes.prod.local.yaml (extending another project file has the merge order). examples/devlanes.prod.yaml is a commented version.
The two projects are separate in devlanes: their own lanes, port blocks, saved services and hostnames (acme.localhost and acme-prod.localhost), so both run side by side in one worktree. A lanes.vars.domain in devlanes.yaml that spells out acme applies to both; set it again in the prod file, for example with ${{ project.name }}. Anything outside devlanes that both reach is shared:
- The shared Compose services are one Compose project for both: the same files under the same name,
infra.project_namewhen set and otherwise the name Compose derives from the directory.devlanes infra downfrom one project stops the other’s containers too. Per-lane Compose services and plaininfra.containersare named after each devlanes project, so each project has its own. - Values built only from
lane.nameorlane.indexare equal in both, sodb_name: acme_${{ lane.name }}gives both projects the same database. Use${{ project.name }}in the value (${{ project.name | replace('-', '_') }}_${{ lane.name }}) when the data must stay apart. - Files written into the worktree, such as
.next,distor.wrangler, are the same folder for both. Point build output at${{ lane.data_path }}where the tool allows it, or run the two at different times.
Two files of one repository that name the same project fail with duplicate_project_name. A project name belongs to one repository: a different repository that uses a name already taken fails with project_name_taken. If you need one name in several repositories, open an issue.
Renaming project: makes a new project with new lanes, domains, ports and containers. The old project’s lanes stay, with the reason config_names_other_project, until devlanes lane gc --yes cleans them once they run nothing (renames and branch switches).
Servers devlanes does not run
Section titled “Servers devlanes does not run”A server started with devlanes exec, or outside devlanes, gets no hostname and is not mirrored to connected machines. To route one, declare its port under infra.external, in devlanes.local.yaml when only you run it: external ports.
Agents and CI
Section titled “Agents and CI”devlanes exec --name smoke -- pnpm test:smoke # an ad-hoc unit with the lane's env; logs, stop and attach work on itdevlanes wait api --timeout 2mdevlanes with --profile e2e --logs-dir devlanes-logs -- pnpm test:e2ewith starts the lane, waits for ready, runs the command with the lane’s environment (DEVLANES_PUBLIC_URL_API, DEVLANES_PUBLIC_URL_WEB, …), writes each unit’s log to --logs-dir, stops what it started and exits with the command’s code. The command also gets NODE_EXTRA_CA_CERTS and DENO_CERT set to the devlanes CA and SSL_CERT_FILE and REQUESTS_CA_BUNDLE set to a bundle of the system roots plus the CA, unless your shell already sets them, so test code can call the lane’s https:// URLs.
In CI the checkout takes lane 0 when it is free, on any branch, detached pull request checkouts included. Outside GitHub Actions, run devlanes setup --yes first. On Linux, also add chrome to tls.trust so Playwright’s Chromium trusts the CA, and expect the proxy on 8443 unless the runner may bind 443.
In GitHub Actions, the devlanes action installs devlanes, trusts the CA on Linux runners and uploads the unit logs when a step of the job fails:
- uses: devlanes-run/action@v0 with: command: with profile: e2e run: pnpm test:e2eIts README covers the other commands, inputs and platforms.