---
title: "Service patterns"
url: https://devlanes.run/docs/guides/services/
description: "Configurations for the shapes most repositories have."
updated: 2026-11-01T23:26:08.000Z
---

# Service patterns

Configurations for the shapes most repositories have. Each one assumes the basics from [getting started](/docs/getting-started/): a service gets `PORT`, a lane gets `lane.domain`, hostnames go through the HTTPS proxy.

## Frontend and API

```yaml
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:

```yaml
  web:
    env:
      API_PROXY_TARGET: "${{ services.api.url }}"
```

## Workers and consumers

```yaml
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](/docs/guides/lifecycle/#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

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

The proxy speaks HTTP/1.1 to upstreams, so gRPC over HTTP/2 does not go through a hostname. Point clients at the port:

```yaml
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](/docs/remote/#mirroring)).

## Several ports, several hostnames

```yaml
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: ws
```

`hosts` 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

```yaml
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 matched
```

Exact 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](/docs/guides/https/#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](/docs/guides/https/#programs-that-cannot-resolve-localhost).

## Replicas

```yaml
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

`autostart: false` is covered in [lane lifecycle](/docs/guides/lifecycle/#services-that-start-on-demand).

## One lane's frontend against another lane's backend

```yaml
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

Installs, migrations and seeds that run at lane events are covered in [lane lifecycle](/docs/guides/lifecycle/#lifecycle-tasks).

## Tags, profiles and templates

```yaml
tags:
  api:
    color: cyan
templates:
  fastify-api:
    platform: node
    tags: [api]
    ready:
      http:
        path: /-/health
services:
  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: production
```

A 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](/docs/guides/lifecycle/#bringing-a-lane-back)).

## 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](/docs/guides/lifecycle/#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.

```yaml
# apps/billing/devlanes.yaml
project: billing
services:
  api:
    run_script:
      file: package.json                 # apps/billing/package.json
      name: dev
    hosts: "${{ lane.domain }}"          # billing.localhost, billing2.localhost
```

A 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

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

```yaml
# devlanes.prod.yaml
extends: devlanes.yaml
project: acme-prod
tasks:
  build:
    run: pnpm build
    when: lane.start
profiles:
  default:
    run_scripts:
      dev: start                         # services whose run_script names dev run start
    env:
      NODE_ENV: production
```

devlanes only finds `devlanes.yaml` by itself. Name another file the first time with the global `--file` flag; after that `--project` finds it:

```sh
devlanes --file devlanes.prod.yaml start
devlanes -p acme-prod status
devlanes -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](/docs/guides/ports-and-env/#extending-another-project-file) has the merge order). [examples/devlanes.prod.yaml](https://github.com/devlanes-run/devlanes/blob/3afa327903b6275d80d44ea599d8b71b274abc95/docs/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_name` when set and otherwise the name Compose derives from the directory. `devlanes infra down` from one project stops the other's containers too. Per-lane Compose services and plain `infra.containers` are named after each devlanes project, so each project has its own.
- Values built only from `lane.name` or `lane.index` are equal in both, so `db_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`, `dist` or `.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](/docs/guides/lifecycle/#renames-and-branch-switches)).

## 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](/docs/guides/infra/#external-ports).

## Agents and CI

```sh
devlanes exec --name smoke -- pnpm test:smoke          # an ad-hoc unit with the lane's env; logs, stop and attach work on it
devlanes wait api --timeout 2m
devlanes with --profile e2e --logs-dir devlanes-logs -- pnpm test:e2e
```

`with` 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](https://github.com/devlanes-run/action) installs devlanes, trusts the CA on Linux runners and uploads the unit logs when a step of the job fails:

```yaml
- uses: devlanes-run/action@v0
  with:
    command: with
    profile: e2e
    run: pnpm test:e2e
```

Its README covers the other commands, inputs and platforms.
