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.
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:
- Commit it to your repo as
nexos.yamlat 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 itsnexos.yamlread. - 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) changesnexos.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.yamlgets 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 reachesmain. - Setting a project up. While your default branch has no
nexos.yamlat 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 anexos.yamlis 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.yamlalready 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.yamlread: 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.nexosif 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$PORTso frameworks pick it up automatically.cpu— vCPU (e.g.0.5,1,2). Default1.memory— vRAM in MB. Default512.
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. Default2.memory— vRAM in MB during the build. Default2048.timeout— max build duration in seconds. Default600(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.
| Service | Versions | Injected env var |
|---|---|---|
postgres | 14, 15, 16, 17, 18 | DATABASE_URL |
redis | 6, 7 | REDIS_URL |
mongodb | 5, 6, 7 | MONGO_URL |
elasticsearch | 7.x, 8.x | ELASTICSEARCH_URL |
minio (S3-compatible) | latest | S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET |
mailpit (SMTP capture + inbox) | latest | SMTP_HOST, SMTP_PORT |
mysql | 8.0, 8.4, 9 | DATABASE_URL, MYSQL_URL |
meilisearch | v1.12, v1.13, latest | MEILISEARCH_URL, MEILISEARCH_API_KEY |
qdrant (vector DB) | v1.12.4, latest | QDRANT_URL, QDRANT_API_KEY |
clickhouse (analytics — Pro+) | 24.8, 25.3, latest | CLICKHOUSE_URL, CLICKHOUSE_DATABASE |
rabbitmq | 3.13, 4.0, 4.1 | AMQP_URL, RABBITMQ_URL |
redpanda (Kafka API — Pro+) | v24.2.7, latest | KAFKA_BROKERS, KAFKA_SASL_USERNAME |
keycloak (OIDC — Pro+) | 25.0, 26.0, latest | OIDC_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 totrueif the key is present. Setfalseto 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. Writeredis: falseto 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
versionthat 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.
analyticsgivesANALYTICS_DATABASE_URL,ANALYTICS_DB_URL,ANALYTICS_DB_HOST, …. They never replace the default'sDATABASE_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'stypeis 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=ANALYTICSto 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-levelapp. An environment counts the sum of itsservicecontainers'cpu/memoryagainst your plan's pool, so size them for a preview, not for production. See Resource Limits.public— iftrue, the container gets its own preview subdomain. Defaultfalse(internal-only).command— override the image CMD. Required for jobs.type—service(default, long-running) orjob(runs once per deploy, must setcommand).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-levelenv. 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 atype: 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— iftrue, 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— iftrue, the preview URL routes to this container instead ofapp. At most one container can set it.run_hooks— iftrue, hooks run inside this container instead ofapp. At most one container can set it.
nodePool
Which compute pool to schedule onto.
type—shared(default, runs on the global pool) orprivate(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 meanstorageMb?,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, andconfigSummaryinGET /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
- AI agent spec — the version to hand to your AI assistant.
- Environment Variables — what gets auto-injected and how secrets are encrypted.
- Deployments — how a push becomes a live URL.