BTSBTS

nexos.yaml

One file at the root of your repo configures your entire preview environment — the Dockerfile, the port, the services, the migrations, the secrets. When it changes on your default branch, BTS applies it before the build starts; a pull request that changes it shows what merging would change. Every field is optional.

Let an AI generate it for you

Open your AI assistant inside your repo and paste this link: test.buildingswell.com/docs/nexos-yaml/ai. That page is a self-contained spec written for AI agents — it tells them exactly how to detect your stack, write your nexos.yaml, and create a Dockerfile.nexos if your repo doesn't have a Dockerfile that works for preview environments. No setup, no back-and-forth.

Where to put it

You have two options. Pick whichever fits your workflow:

  1. Commit it to your repo as nexos.yaml at the root. It's applied when it changes on your default branch, and checked on every pull request that changes it (see how changes are applied). A pull request from a fork never has its nexos.yaml read.
  2. Paste it in the dashboard. Open Project → Settings → YAML, paste, click Apply. Useful when you want to try a config without a commit, or to bulk-import env vars and services.

If both exist, the committed file wins for fields it sets. Anything it omits keeps the dashboard value. Treat the repo file as the source of truth for everything you want version-controlled.

Multi-repo projects. On a project that links several repositories (Enterprise), nexos.yaml is read from the primary repo only; a nexos.yaml in a secondary repo is ignored. Which repo each container builds from is managed in the dashboard or API (Settings → Repositories and the container's Repository field), not in this file — there is no containers[].repo key. See Projects → Multi-repo.

How changes are applied

Config is project-wide: every environment of a project deploys with the same containers, addons and env vars. So only one branch is allowed to change it.

  • Your default branch applies it. When a push to the repo's default branch (usually main) changes nexos.yaml — including the push that merges a pull request — BTS applies it to the project. Every environment picks it up on its next deploy. This happens even if the default branch has no preview environment of its own.
  • Any other branch is checked, not applied. A pull request that changes nexos.yaml gets a dry run: the PR comment, a BTS config check, and the deployment in the dashboard list what merging will change (containers: add worker; env: add API_URL; app.memory: 512 → 1024), plus any errors and warnings. The PR's own preview is built with the current config. An invalid file fails the BTS config check, so you can make it a required check and catch it before it reaches main.
  • Setting a project up. While your default branch has no nexos.yaml at all, the branch that adds one applies it, so your setup pull request's preview (and every fix you push to it) runs with the file. Once a nexos.yaml is merged, only the default branch applies it.
  • A new project on a repo that already has one. A project created in the dashboard picks up the nexos.yaml already on your default branch with its first deploy, as long as that pull request doesn't change the file. From then on, only the default branch applies it.
  • Trying a change before merging. Open the deployment's config panel in the dashboard and choose Apply to project (or Apply & rebuild this preview). This applies the branch's file to the whole project, not just that preview, so every environment gets it on its next deploy. Merging later applies the same file again.
  • Pull requests from forks never have their nexos.yaml read: the file controls project-wide settings, and a fork is written by someone without access to your repo.
  • Deleting the file resets nothing. The project keeps its current config.

Private repos are read with the same GitHub access BTS uses to clone them (the GitHub App, a token, or your GitHub login).

Full example

Every field below is optional. Drop in just the ones you care about.

# nexos.yaml
name: my-app

build:
  dockerfile: ./Dockerfile        # or ./Dockerfile.nexos
  context: .
  args:
    NODE_VERSION: "20"

app:
  port: 3000                       # injected as $PORT
  cpu: 1                           # cores
  memory: 512                      # MB

buildResources:
  cpu: 2
  memory: 2048
  timeout: 600                     # seconds

services:
  postgres: true                   # shorthand: enabled with defaults
  redis:
    enabled: true
    version: "7"
  mongodb: false                   # explicitly disabled

hooks:
  on_deploy: npx prisma migrate deploy
  on_new: node scripts/seed-admin.js

env:
  NODE_ENV: production
  LOG_LEVEL: info

containers:
  - name: worker
    dockerfile: ./worker/Dockerfile
    cpu: 0.5
    memory: 256
    command: node worker.js
    depends_on: [api]
    watch_paths: [worker/**]       # rebuild only when these paths change
    env:
      QUEUE_NAME: jobs

  - name: api
    dockerfile: ./api/Dockerfile
    port: 3001
    public: true                   # gets its own preview subdomain

Minimal example

For a Node app with Postgres, this is all you need:

# nexos.yaml — minimum viable
app:
  port: 3000
services:
  postgres: true

Field reference

name

Project display name. Used in the dashboard and in preview URLs: {branch}-{name}.test.buildingswell.com.

build

  • dockerfile — path to the Dockerfile. Default ./Dockerfile. Use ./Dockerfile.nexos if you want a BTS-specific build alongside an existing Dockerfile.
  • context — build context directory. Default ..
  • args — key/value pairs passed as --build-arg. Stored in plaintext; not for secrets. Removing a key from the file removes it from the project, like env.

app

The main container — what users hit when they open the preview URL.

  • port — port your app listens on. Also injected as $PORT so frameworks pick it up automatically.
  • cpu — vCPU (e.g. 0.5, 1, 2). Default 1.
  • memory — vRAM in MB. Default 512.

Once the file has containers, there is no separate app container: each container's own cpu/memory apply, and these app values size nothing.

buildResources

Limits applied during the image build, separate from runtime limits.

  • cpu — vCPU for the build. Default 2.
  • memory — vRAM in MB during the build. Default 2048.
  • timeout — max build duration in seconds. Default 600 (10 minutes). Hung builds get killed.

services

Managed addons that get provisioned alongside your app. Each environment gets its own copy — push a new branch, get a fresh database. Use the boolean shorthand to enable with defaults, or an object to pin a version or storage size.

ServiceVersionsInjected env var
postgres14, 15, 16, 17, 18DATABASE_URL
redis6, 7REDIS_URL
mongodb5, 6, 7MONGO_URL
elasticsearch7.x, 8.xELASTICSEARCH_URL
minio (S3-compatible)latestS3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET
mailpit (SMTP capture + inbox)latestSMTP_HOST, SMTP_PORT
mysql8.0, 8.4, 9DATABASE_URL, MYSQL_URL
meilisearchv1.12, v1.13, latestMEILISEARCH_URL, MEILISEARCH_API_KEY
qdrant (vector DB)v1.12.4, latestQDRANT_URL, QDRANT_API_KEY
clickhouse (analytics — Pro+)24.8, 25.3, latestCLICKHOUSE_URL, CLICKHOUSE_DATABASE
rabbitmq3.13, 4.0, 4.1AMQP_URL, RABBITMQ_URL
redpanda (Kafka API — Pro+)v24.2.7, latestKAFKA_BROKERS, KAFKA_SASL_USERNAME
keycloak (OIDC — Pro+)25.0, 26.0, latestOIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET

Postgres, MySQL and MongoDB connect as a login of the environment's own that reaches only its own database; the other services share one credential per project. See what previews share.

Object form fields:

  • enabled — defaults to true if the key is present. Set false to disable.
  • version — pin a specific version string.
  • storageMb — persistent volume size in MB.

What BTS does when it can't do what the file says:

  • Leaving an addon out doesn't turn it off. An addon that's enabled on the project stays enabled even if services: doesn't list it, because switching one off stops it on every node. Write redis: false to switch it off — its data is kept for switching it back on. BTS warns when an enabled addon isn't listed. Removing an addon (which deletes its data) is only done in the dashboard.
  • A version that isn't in the table above isn't used. An existing addon keeps its current version, and a new one gets the default. Either way you get a warning listing the available versions.
  • A major version change isn't applied from the file. Postgres 16 → 17 (or Mongo, MySQL, Elasticsearch across majors) can't read the old data, so it starts every environment's data fresh — a change you confirm in the dashboard, not one any branch can make. The file keeps the current version and warns. A version in the same major is applied, by each node on its next deploy.
  • Pro+ addons can't be added on a lower plan. They're skipped with a warning, and the rest of the file still applies.

services: named instances

A key that is an addon type configures that type's default instance. Any other key with a type: adds a named instance: a second Postgres, a separate Redis for sessions, and so on. Each named instance runs as its own side-car, with its own version, image variant, storage and credentials, shared by every environment the same way the default is. Each environment gets its own database (or index, bucket, …) inside it.

services:
  postgres:                        # the default instance → DATABASE_URL, DB_URL, …
    version: "16"
  analytics:                       # a NAMED instance: any other key + type:
    type: postgres
    version: "17"                  # its own side-car, its own version
    storageMb: 10240
  cache:
    type: redis                    # → CACHE_REDIS_URL, CACHE_REDIS_HOST, …

containers:
  - name: reporting
    dockerfile: ./reporting/Dockerfile
    env:
      # Point one container's DATABASE_URL at the named instance:
      DATABASE_URL: ${ANALYTICS_DATABASE_URL}
  • Variables: a named instance injects exactly the variables its type's default does, prefixed with its name in upper case. analytics gives ANALYTICS_DATABASE_URL, ANALYTICS_DB_URL, ANALYTICS_DB_HOST, …. They never replace the default's DATABASE_URL. Every container gets all of them, and ${ANALYTICS_DATABASE_URL} can be interpolated into your own variables. For Postgres, MySQL and MongoDB each environment gets its own login in every instance, just as with the default. The "App login" setting is per instance.
  • Names: letters, digits and single underscores (dashes in the key become underscores), up to 16 characters, unique across all addons of the project (the name is the variable prefix), and not an addon type or bts/nexos. A project can have up to 10 named instances.
  • Removing a named instance, in the dashboard, deletes its side-car and all of its data. Leaving it out of the file doesn't, as with any addon. To turn one off, write analytics: { type: postgres, enabled: false }. Changing an existing instance's type is refused with a warning, because it would mean replacing its data.
  • Seeds, consoles, dumps, resets, logs and external endpoints work per instance. In the API, add ?instance=ANALYTICS to any /addons/:type/… route. Leaving it out means the default instance.

hooks

Commands that run inside your app container at deploy time.

  • on_deploy — runs on every deploy (new and incremental). For migrations: prisma migrate deploy, rails db:migrate, alembic upgrade head.
  • on_new — runs only the first time an environment is created, after the database is seeded. For one-time setup: create admin user, provision an API key, etc.

env

Shared environment variables, encrypted at rest with AES-256-GCM. The file owns the keys it sets: delete a key from env: and it's removed from the project the next time the file is applied. Variables you set in the dashboard or API are never removed by the file unless it lists them too, and a secret set there is never taken over even then. If you edit a file-owned variable in the dashboard, it becomes yours and the file stops removing it. A file without an env: block leaves env alone (write env: {} to remove every key the file set).

For real secrets you don't want in git, set them in the dashboard instead. BTS warns if a committed value would overwrite a secret set in the dashboard.

containers

Extra containers that deploy with your app — workers, separate APIs, background jobs. Reconciled by name: containers in the file are upserted, ones removed from the file get torn down.

  • name — lowercase, alphanumeric + hyphens.
  • dockerfile, context, port, cpu, memory — same as the top-level app. An environment counts the sum of its service containers' cpu/memory against your plan's pool, so size them for a preview, not for production. See Resource Limits.
  • public — if true, the container gets its own preview subdomain. Default false (internal-only).
  • command — override the image CMD. Required for jobs.
  • type — service (default, long-running) or job (runs once per deploy, must set command).
  • depends_on — list of sibling container names this one waits on. BTS walks the DAG topologically, so independent containers build in parallel.
  • env — per-container overrides on top of top-level env. Same removal rule as env.
  • watch_paths — list of paths/globs (default: the container’s build context) that scope change detection. On a push, a container is rebuilt — or a type: job (migration) re-run — only when one of these paths changed since the last successful deploy; otherwise its running container is kept as-is, so a push that touches only one service in a monorepo doesn’t rebuild the rest. Point a migration job at its schema files (e.g. ["migrations", "prisma/schema.prisma"]) so schema changes still re-run it while unrelated pushes skip it.
  • optional — if true, a branch whose checkout lacks this container’s build context or Dockerfile deploys without it (no container) instead of failing. Set it when adding a container, so branches cut before its files existed keep deploying.
  • default — if true, the preview URL routes to this container instead of app. At most one container can set it.
  • run_hooks — if true, hooks run inside this container instead of app. At most one container can set it.

nodePool

Which compute pool to schedule onto.

  • type — shared (default, runs on the global pool) or private (only your or your team's nodes).

Which of your nodes run builds and previews, and which run workflows, is picked in the dashboard (Settings → Resources) or the API — not here, since node ids belong to your workspace and any branch can edit this file.

Errors, warnings and where to see them

  • An invalid file applies nothing. Broken YAML, a wrong type, or an unknown top-level key rejects the whole file, and the project keeps its previous config. Every problem is listed, not just the first.
  • Keys BTS doesn't know are warnings. A key nested inside a section that isn't part of the schema is ignored, and you're told about it with a suggestion where there is one: storage_mb → did you mean storageMb?, healthcheck → BTS has no custom health checks. The rest of the file still applies.
  • Where results show up: on the pull request (the preview comment and the BTS config check), on the deployment in the dashboard, in Project → Settings → YAML for the latest apply from your default branch, in the activity feed, and in the API (GET /deployments/:id, and configSummary in GET /deployments/:id/logs). Env values are never shown, only their names.
  • The dashboard editor validates before applying, and shows what the apply changed and any warnings, so paste-and-apply is non-destructive if the file is broken.
  • Real secrets belong in dashboard env vars, not committed env: blocks.

See also