BTSBTS

Projects

A project is a GitHub repository connected to BTS. Every branch you push becomes its own preview environment. Configure the project once; every branch inherits it. Enterprise workspaces can link several repositories to one project.

Rule of thumb. Project = one repo (or a set of linked repos). Branch name = environment. Push to any linked repo = deployment.

Creating a project

  1. Pick a repository. Selecting it installs a webhook for push, pull_request, and delete events. Preview environments spin up when a PR opens; pushes after that redeploy onto the existing environment.
  2. Name it. The slug appears in every preview URL: {branch}-{project}.test.buildingswell.com.
  3. Pick services and starting limits. All editable later, or version-controlled via nexos.yaml.

Configure it once with nexos.yaml

The fastest way to set up a project is to commit a nexos.yaml at the repo root. It captures the Dockerfile, port, services, migrations, and env vars in one file. BTS applies it whenever it changes on your default branch, and shows a pull request that changes it what merging will do (how changes are applied).

# nexos.yaml
name: my-app

build:
  dockerfile: ./Dockerfile

app:
  port: 3000

services:
  postgres: true
  redis: true

hooks:
  on_deploy: npx prisma migrate deploy

Don't want to write it by hand? Point your AI assistant at the AI Agent Spec and it will generate the file (and a Dockerfile.nexos, if needed) for your repo.

What the dashboard controls

Everything in nexos.yaml, plus:

  • Secrets. API keys, OAuth credentials, anything you don't want in git. Encrypted with AES-256-GCM. See Environment Variables.
  • Ownership. Move a project between personal and team ownership; it counts against that workspace's plan quotas.
  • Node placement. For builds and previews: shared (the global pool, the default) or private — any of your own / your team's nodes, or only the ones you pick. A preview runs on the node that built it. A project with addons runs a copy of them on each node, and every environment stays on the node holding its data. For workflows: beside each preview (the default), or on nodes of their own so CI scales separately — see Workflows.
  • Which pull requests get a preview. Skip Dependabot, Renovate or anything labelled no-preview, and choose whether drafts get one — see below.
  • Fork pull requests. Whether a pull request from a fork gets a preview, and with which of your secrets. Off by default — see below.
  • Database logins. Whether each environment's Postgres, MySQL and MongoDB login is its own (the default) or the shared admin account — see below.

If both a committed nexos.yaml and dashboard settings exist, the file wins for fields it sets. Fields it omits keep their dashboard value.

Which pull requests get a preview

Every pull request that isn't a draft gets a preview, which is not what you want from a bot that opens one per dependency bump. Under Project settings → Resources → Which pull requests get a preview:

  • Preview draft pull requests. Off by default: a draft gets its preview when it is marked ready for review.
  • Skip pull requests with these labels. Not case-sensitive. Labels are a switch: adding one to a pull request archives its preview, and removing it deploys one.
  • Skip pull requests from these branches. Globs on the pull request's branch: * matches within one path segment, ** across them, so dependabot/** covers every Dependabot branch and renovate/** every Renovate one.

A skipped pull request gets a neutral BTS preview check saying which rule matched (with the GitHub App installed; without it, a passing bts/preview commit status saying the same), and no comment. Either way it counts as passing, so a required preview check doesn't block it from merging.

For bots, prefer a branch pattern. Dependabot and Renovate add their labels just after opening the pull request, so a label rule takes down a preview that has already started building; a branch pattern stops it before it starts.

The rules decide whether a preview is created. A preview that already exists — deployed by hand, or before you added the rule — keeps updating on pushes until you archive it, and you can still deploy any branch from the dashboard.

Pull requests from forks

A pull request from a fork is written by someone without write access to your repository, and a preview runs its code — the Dockerfile, the app, its migrations — on your project's nodes, with whatever the project hands its containers. On a public repository, anyone can open one. So fork previews are a per-project choice, under Project settings → Resources → Fork pull requests:

  • Don't deploy (the default). No preview. BTS comments on the pull request — and, with the GitHub App installed, adds a neutral BTS preview check — saying why, and how a maintainer can turn it on.
  • Deploy without secrets. The preview gets your plain variables only. Variables marked secret, BuildKit secrets, SSH credentials, registry logins and the database seed are withheld, and SSH is off. Its Postgres, MySQL and MongoDB logins are its own, whatever the project's database login setting: they open the fork's own databases and nothing else, never your other previews'. It is not a full sandbox, though. The project's other services share one credential across every preview (Redis and Elasticsearch have none at all), so do the Mailpit inbox and the private network your previews' containers sit on — see what previews share. Keep sensitive data out of those, or out of fork previews, accordingly. If a node the project builds on has pulled private base images, fork pull requests get the “don't deploy” treatment instead.
  • Deploy with secrets. Like a branch of your own repository, every secret included — which means anyone who can open a pull request can read them. Only for forks whose authors you trust. Choosing it needs permission to manage the project's secrets.

Whatever you choose:

  • A fork never runs workflows, and its nexos.yaml is never applied — the preview builds with the project's current configuration.
  • A fork's preview is its own environment, named <fork owner>:<branch>, so a fork's main can never take over your main preview.
  • Pushes to the fork redeploy its preview, and closing the pull request archives it.
  • Changing the setting takes effect on the pull request's next push (or a close and reopen), and on the next rebuild of a fork preview that is already running.

Database logins, and what previews share

Each node your previews run on has its own copy of each of the project's services (one Postgres, one Redis, …), shared by every preview on that node, and a preview keeps its data on its node. Each preview gets its own slice of that copy: its own database, index prefix, bucket or queue vhost. For Postgres, MySQL and MongoDB the slice comes with its own login. DATABASE_URL, PGUSER/PGPASSWORD, MYSQL_USER, MONGO_URL and the rest carry a login named after the preview's database, and it can open that database and no other. The admin account — the Postgres superuser, MySQL's root — stays with BTS, which uses it for seeds, resets, exports and per-job workflow databases.

  • Postgres: the login owns its database and everything in it, and may create databases of its own (for test runners and rails db:create). It is not a superuser. Your image variant's extension (vector, postgis, timescaledb) is installed up front, so CREATE EXTENSION IF NOT EXISTS in your migrations just works. Trusted extensions (pgcrypto, uuid-ossp, pg_trgm, citext, …) need nothing.
  • MySQL: a user with every privilege on its own database. MongoDB: a user defined in its own database (authSource is that database, and is already in MONGO_URL).
  • The dashboard's connection details and Connect endpoints show the same login, so a connection string you paste into a tool is no stronger than the app's. The SQL console acts as the preview's login too, so a table you create there belongs to the app. Start with SET ROLE NONE; for a superuser-only statement.
  • Previews that existed before switch on their next deploy. That deploy rebuilds every container, hands the preview every table its app ever created, and keeps the running version serving until the new one is up. If the login can't be set up, the deploy fails and the previous version keeps running.

An app that genuinely needs superuser powers — an extension only a superuser may install, fixtures that ALTER TABLE … DISABLE TRIGGER ALL, CREATE ROLE — can go back to the shared admin account under Project settings → Services → App login → Shared admin login. That login reaches every preview's database. Fork pull request previews keep their own regardless.

What every preview of a project still shares:

ServiceEach preview getsCredential
Postgres, MySQL, MongoDBits own databaseits own login
Redisits own logical database numbernone — any preview can select any number
Elasticsearchits own index prefixnone
Qdrant, Meilisearchits own collection / index prefixone API key per project
MinIOits own bucketone key pair per project, on a public endpoint
ClickHouseits own databaseone user per project
RabbitMQits own vhostone user per project
Redpandaits own topic prefixone SASL user per project
Keycloakits own realmone admin per project
Mailpita tag on its messagesnone — one inbox

On each node, a project's containers also sit on one private network, where a preview can reach its siblings' app containers by hostname. Postgres database names are visible to every login, and each service's CPU, memory and connections are one pool for the previews on that node.

GitHub authentication (private repos)

Build nodes clone your repo over HTTPS. Public repos need no credentials; private repos need one of:

  • GitHub OAuth. Click Authenticate with GitHub in project settings. BTS stores your OAuth token AES-256-GCM encrypted and reuses it for every clone. One click covers every project on the same GitHub account.
  • Personal Access Token (PAT). Paste a token into Settings → GitHub Authentication → Personal Access Token. Use this when OAuth isn't an option — most commonly when your GitHub account is already linked to a different BTS user (the OAuth flow rejects a second link). Generate one at github.com/settings/tokens with the repo scope, or a fine-grained token with Contents: Read on the specific repository.

When both are configured for a project, the PAT takes precedence. PATs are scoped to the repository they're saved on and stored AES-256-GCM encrypted; the dashboard only ever shows a Stored badge — the token itself never leaves the server after you save it.

Multi-repo projects (Enterprise)

One project can build from several GitHub repositories — a frontend repo and an API repo that only make sense previewed together, for example. Each container of the project is bound to exactly one of the linked repos, and every branch still maps to a single preview URL.

Linking repositories

  • Settings → Repositories. Paste a GitHub URL and click Link. The repo is verified with the credentials you give it (see below) and its default branch is read from GitHub.
  • One primary repo. The repo the project was created with is the primary. It is where nexos.yaml is read from, the default binding for new containers, and what the project header shows. You can make another repo primary later; container names and preview URLs do not change when you do.
  • Containers required first. Linking a second repo is only possible once the project defines at least one container (a containers: block in nexos.yaml, or the Services tab). The legacy single-Dockerfile build has no way to say which repo it builds from.
  • Per-container binding. Each container has a Repository field (repoId in the API). Leave it empty to build from the primary repo. The container's Dockerfile path, build context, and watch_paths are all relative to that repo's root.
  • Linking is Enterprise-only. Free, Pro and Team workspaces see a disabled add form. Downgrading a workspace stops new links but does not stop an existing multi-repo project from deploying.

Which branch does each repo use?

An environment is still named after a branch. When branch feat-x deploys, every linked repo contributes a commit:

  • Same branch name links. If the repo has a feat-x branch, its tip is used.
  • Missing → the repo's fallback branch. If it doesn't, the repo's fallback branch is used instead. It is auto-detected from GitHub's default branch when you link the repo and is editable per repo in Settings → Repositories.
  • Pinned until touched. A fallback branch advancing (someone merges to main in the API repo) does not fan out to every environment that falls back onto it. Each environment keeps the commit it last built and picks up the new tip on its next deploy — a push to its own branch in any repo, a PR event, a manual deploy, or a rebuild. Every deploy re-resolves every repo.
  • Branch deletion. Deleting feat-x in one repo while it still exists in another triggers an incremental deploy with that repo on its fallback branch. The environment is only torn down once the branch is gone from every linked repo.

Pull requests and PR surfaces

An open (non-draft) PR on branch feat-x in any linked repo creates the environment; other repos join by branch existence alone, no PR needed there. Every linked repo that has an open PR on the branch gets the full set of PR surfaces — sticky comment, check run, GitHub Deployment — for the same preview URL.

Credentials and webhooks

  • Per-repo credentials. Each linked repo has its own GitHub OAuth toggle, optional PAT, and GitHub App installation. A private secondary repo that can't be reached with the credentials you give it is rejected at link time rather than on the first deploy.
  • Webhook wiring. A push to any linked repo must reach BTS. If the BTS GitHub App is installed on the repo, nothing to do. Otherwise add a webhook on that repo with the project's Payload URL and secret — the same secret is shared by every linked repo (the dashboard's Webhook card and nexos_get_webhook_info list every repo).
  • Node agent version. Multi-repo deploys are only scheduled onto nodes running agent 0.2.0 or newer. Agents auto-update, so this only matters if you pinned a private node to an older build.

Repository links are managed in the dashboard or API only — nexos.yaml has no repo-binding key, and only the primary repo's file is applied.

Lifecycle hooks

Two hooks run inside the app container at deploy time:

  • on_deploy — runs on every deploy. Database migrations live here.
  • on_new — runs only the first time an environment is created. One-time setup (admin user, API key provisioning).

Resource limits

Each project can cap vCPU and vRAM for its containers — both runtime and build-time. Defaults come from your plan; override per project in the dashboard or in nexos.yaml. Full breakdown: Resource Limits.