karts.yml. How Karts builds your branch.
karts.yml sits at the top of the repository. karts init writes this starter file when there is none:
runtime: node22 # or node24, python312, python314, go, ruby33, php83 database: postgres migrations: db/migrations # numbered SQL files: 0001_init.sql, 0002_add_orders.sql seed: npm run seed install: npm ci start: npm start # must listen on $PORT health: / # readiness path env: # plain, non-secret configuration only NODE_ENV: development
karts version --capabilities prints the keys, service keys and features your CLI accepts. karts check-config checks the file with the same parser the Karts host uses.
Keys
| Key | Default | What it does |
|---|---|---|
project | none | The project this repository belongs to. Written by karts init. karts up refuses to run without it. |
runtime | node22 | The runtime image: node22, node24, python312, python314, go, ruby33 or php83. Any other name is refused with the list. What each image holds: The VM. |
database | postgres | The database inside the VM: postgres, mysql (MySQL 8.4) or sqlite. Each environment gets its own. none for an app with no database, such as a static site. See MySQL and SQLite. |
migrations | none | A directory inside the repository holding numbered .sql files. Karts checks and applies them itself. With migrations_layout, the directory that layout reads. |
migrate | none | Your own migration command, for example npx prisma migrate deploy or python manage.py migrate. Karts runs it in the VM on every build, after install. When it is set, your tool applies every migration and Karts applies none. See Your own migration tool. |
schema | none | For migrations that cannot build an empty database: the template gets its schema from the base branch's ORM models, and a branch's new migrations still run. See Schema from your ORM. |
migrations_layout | karts | How your tool names its files: prisma, golang-migrate, knex, sequelize, typeorm, rails, laravel, django, alembic or files. Karts uses it to find a branch's new migrations. |
migrations_glob | the layout's | Which files are migrations, relative to the repository root, instead of the layout's patterns: one glob or [a, b]. ** matches any number of directories. Not with migrations. |
install | none | Installs dependencies, for example npm ci. It gets no env: and no database. |
seed | none | Loads demo data. Runs once, when the template database is built from the default branch. |
seed_fail_on | none | error: a seed or migrate that prints a line with ERROR, FATAL or Unhandled fails, even when it exits 0. Without it, Karts only warns: on your Mac in the up output; in the cloud, for now, only in karts logs --building while it builds. |
seed_snapshot | none | A masked copy of real data, made by karts db snapshot, loaded instead of seed. A path inside the repository that the command printed. See Masked copies of real data. |
masking | none | Rules for karts db snapshot: which tables to copy and how to mask each column. Nothing else reads them. |
base | none | The branch your branches start from, if it is not the default branch, for example develop. Its migrations and seed build the template. --base wins. |
start | required | Starts your app. It must listen on $PORT on all interfaces, not only on 127.0.0.1. Not with services. |
health | / | The path Karts requests to decide the app is ready. An answer below 500 within 60 seconds counts. Not with services. |
kind | none | static: Karts serves a built folder itself, with no start or health. See Static sites. |
build, dir, spa | none | With kind: static only: the command that builds the site, the folder to serve (required), and spa: true for single-page app routing. |
env | none | Plain, non-secret variables, as indented NAME: value lines. Values may use ${PGHOST} and other variables; see Variables in env. Keys and passwords go in secrets. |
secrets | none | Names of secrets the app gets, as [NAME, …]. The values are set with karts secret set, never in this file. A service can list its own. See Secrets. |
secrets_in | none | [migrate, seed], or one of them: those steps get the top-level secrets too. |
services | none | Instead of start and health: one to eight named processes in the same VM, each with its own port and URL, or a worker with none. See Services. |
redis | off | true runs Valkey, a Redis-compatible server, inside the VM and sets REDIS_URL. See Redis. |
mail | off | true runs a test inbox that catches the app's mail, and sets SMTP_HOST, SMTP_PORT and SMTP_URL. karts up prints the inbox's address. Nothing is sent for real. |
fakes | none | Stand-ins for outside services: email APIs, Google and GitHub sign-in, Stripe. See Fake outside services. |
stubs | none | Canned answers for any other outside API. See Fake outside services. |
internet | off | What the running app may reach on the internet: named presets, hosts you list, or full: true. Build steps never get it. See Internet access. |
recordings | none | Records the app's calls to the hosts you list, then replays them without the internet. See Recordings. |
mocks | none | Answers a host from its OpenAPI spec. See OpenAPI mocks. |
links | none | Other Karts projects this app calls, such as its backend. The app gets the linked environment's URL in a variable, in the cloud and with karts up --local. See Links. |
allow_origins | none | Browser origins, such as your Vercel or Netlify previews, allowed to call this app cross-origin. At most 20. Karts validates them and passes them as KARTS_ALLOWED_ORIGINS; your app answers CORS. See Frontends. |
Format
- A strict subset of YAML:
key: valuelines and#comments, spaces only. A tab is refused. - Indentation is fixed: two spaces before
env:entries,redis:settings,allow_origins:items and service names; four before a service's keys; six before a service's ownenv:entries.links:entries follow the same two-then-four pattern, or fit on one line:api: { project: shop-api, env: API_URL }. - Lists (
allow_origins,migrations_glob,fakes) are- itemlines or one[a, b]line.fakes:can also be two-spacename: {option: value}lines, and eachstubs:ormocks:entry is a- host:line followed by four-space keys. Underinternet:andrecordings:, keys are indented by two spaces and each list is one[a, b]line. - An unknown key, or a key set twice, is refused with the line number.
env:cannot setDATABASE_URL,PORT, the MySQL and SQLite names above, or anything starting withPGorKARTS_. Withredis:on, it cannot setREDIS_URL,REDIS_HOSTorREDIS_PORTeither. Karts sets those.
What your app gets
| Variable | Value |
|---|---|
PORT | The port to listen on: 3000 for the only or first service. |
DATABASE_URL, PG* | The environment's own Postgres, inside the VM, as one URL and split into PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE and PGSSLMODE. It has no TLS: sslmode=disable. |
DATABASE_URL, MYSQL_* | With database: mysql: mysql://…, and MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE and MYSQL2_URL. |
DATABASE_URL, SQLITE_* | With database: sqlite: file:…, the plain path as SQLITE_PATH, and SQLITE3_URL. |
| Your secrets | Only the names in secrets:, with the values set by karts secret set. |
REDIS_URLREDIS_HOST, REDIS_PORT | Only with redis:: redis://127.0.0.1:6379. |
KARTS_SERVICE, KARTS_URL | Only with services:: this service's name and its own public URL. |
KARTS_URL_<NAME>KARTS_INTERNAL_URL_<NAME> | Only with services:: each web service's public URL, and its http://127.0.0.1:<port> address inside the VM. <NAME> is the service name in upper case, with - as _. |
KARTS_LINK_URL_<NAME>KARTS_LINK_INTERNAL_URL_<NAME> | Only with links:: the linked environment's public URL, and its private address inside this VM (see Links). |
SMTP_HOST, SMTP_PORT, SMTP_URL | Only with mail:: 127.0.0.1, 1025 and smtp://127.0.0.1:1025, the test inbox. |
| The fakes' keys and URLs | Only with fakes:: what each fake's app code needs, such as STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET or GOOGLE_CLIENT_ID. They are test values. |
KARTS_ALLOWED_ORIGINS | Only with allow_origins: the patterns, canonical and comma-separated, for your app's CORS check. With karts up --local, then the exact origins of the local environments a wildcard matches at that up. |
Which step gets what:
| Step | Runs in | Gets |
|---|---|---|
install | repository root | No env:, no database, no PORT. The registry settings (network). |
migrate, seed | repository root | env:, the database, Redis; the registry settings. |
service build | the service's dir | Everything its start gets except PORT; the registry settings. |
start, one service | repository root | env:, the database, Redis, links' variables, PORT, KARTS_ALLOWED_ORIGINS. |
service start | the service's dir | The same, plus the service's own env: and the KARTS_* service variables. A worker gets no PORT and no KARTS_URL. |
Every step runs as an unprivileged user under /bin/sh -c. Write start: exec … so your app receives signals directly. The single-service form gets no variable holding its own public URL; if your app needs one, use a one-service services: block and ${services.<name>.url}.
Migrations
- Every file in the migrations directory must be named
<number>_<name>.sqlor<number>-<name>.sql, directly in that directory. Any other file is refused, and so are two files with the same number. - Numbers are compared as integers, so
0011and11are the same number. - Karts applies each file in its own transaction. A file whose first line is
-- karts:no-transactionruns without one. - A branch's new migrations must be numbered above the highest number on the default branch. A number another branch has already claimed is refused, with the next free number in the message.
- A migration that drops or rewrites data is refused unless you pass
--allow-destructive. The check is rule-based: it recognises the statements listed in the FAQ, not every way to lose data.
Your own migration tool
migrate: npx knex migrate:latest
migrations: server/migrations
migrations_layout: knex
- The template runs the default branch's
migrateafter install. Each revision runs your branch'smigrateafter install, so a branch's new migration is applied on a plainkarts up. - Karts lists the files your branch adds in the layout and prints them. A migration the default branch already ran that your branch edits or deletes is refused, except with
--from-scratch. prismaandgolang-migratefollow the numbered rules above, and their numbers are claimed. Withoutmigrate, Karts applies them itself: Prisma'sNN_name/migration.sqlfolders and golang-migrate's.up.sqlfiles, never.down.sql. Timestamp layouts (knex,railsand the like) claim nothing; a file older than the default branch's newest is a warning.- Code layouts (
knex,sequelize,typeorm,rails,laravel,django,alembic,files) needmigrate. Usefileswithmigrations_globfor Drizzle, goose and other tools. - New SQL files are checked for destructive statements before any VM starts. Code migrations cannot be read, so Karts compares the database schema before and after
migrateruns and refuses dropped schemas, tables and columns, narrowed column types and emptied tables unless you pass--allow-destructive. It cannot seeUPDATEs, and it checks only the environment's own database.
Schema from your ORM
Use schema: when your migrations cannot build an empty database. Typical cause: the first tables came from the ORM's sync (TypeORM's synchronize, prisma db push) and are in no migration, so an old migration changes a table that no migration creates. With schema:, Karts builds the template from the base branch's models and records the base's migrations as applied without running them. Each branch's new migrations then run for real. Without schema:, nothing changes.
migrations: src/migrations migrations_layout: typeorm install: npm ci && npm run build schema: from: typeorm datasource: dist/data-source.js # the file typeorm -d takes
migrations_layout: prisma schema: from: prisma # prisma/schema.prisma and prisma.config.ts
migrate: npx sequelize-cli db:migrate migrations: migrations migrations_layout: sequelize schema: from: custom unverified: true # you accept that Karts checks less ledger: SequelizeMeta sync: node scripts/sync-models.js baseline: node scripts/mark-applied.js drift: node scripts/schema-diff.js
typeorm: Karts runs the TypeORM CLI itself. Needsmigrations_layout: typeorm, and nomigrate. TypeORM 0.3.prisma: Karts runsprisma db pushfor the base, and applies each newmigration.sqlitself. Needsmigrations_layout: prisma, and nomigrate. Prisma 7.custom: any other tool, through your own commands. Karts runs them but does not check what they do, so it needsunverified: true.- Another TypeORM or Prisma version fails after install, before sync, and says to use
custom.
| Key | For | What it does |
|---|---|---|
from | all | typeorm, prisma or custom. Required. |
dir | typeorm, prisma | Where the tool runs, relative to the repository root. Default: the root. |
datasource | typeorm | The data source file typeorm -d takes, relative to dir. Required. It may be a build output, such as dist/data-source.js. |
cli | typeorm | typeorm, typeorm-ts-node-commonjs or typeorm-ts-node-esm. Default: typeorm for a .js, .cjs or .mjs data source, typeorm-ts-node-commonjs for .ts. |
ledger | typeorm, custom | The table your tool records migrations in, as name or schema.name. TypeORM's default is migrations. Required for custom with migrate. |
prisma_schema, prisma_config | prisma | The schema and config files every Prisma step uses, relative to dir. Defaults: prisma/schema.prisma and prisma.config.ts. |
on_drift | all | warn (default) or fail: what a branch's model changes that no migration makes do (below). |
unverified | custom | Must be true. |
sync | custom | A command that builds the schema from your models in an empty database. Required. |
baseline | custom | A command that records every migration in the tree as applied and runs none. Required with migrate, refused without it (Karts then applies the files itself). |
drift | custom | A read-only command that compares the database with your models. Exit 0 with no output means no drift. Required. |
- Drift. After a branch's migrations run, Karts compares the database with the branch's models. A change in the models that no migration makes is drift. Drift that would drop a table or column, narrow a type or empty a table fails the
up, as a destructive migration does, unless you pass--allow-destructive. Other drift followson_drift: a warning for each statement, or a failure. --schema-from-branch(karts upandkarts up --local) builds the schema from this branch's models instead of the base's, and records all of its migrations as applied, running none. Use it when the base cannot sync, or the branch has no base.upwarns that no migration ran.- What Karts checks. With
typeormandprisma: the synced schema must match the models exactly, each new migration is proved to have run, destructive drift is refused, and tool output Karts cannot read fails the build. Withcustom, Karts checks none of these and prints that once per build. - Where sync runs. In the cloud, only inside the environment's VM, against its own empty database. On your Mac, your tool runs on your machine. Karts first checks that the target is its own empty database, as far as it can tell; with
customit cannot check. - Not with MySQL, SQLite,
database: noneorsecrets_in. Views, functions, triggers and rows that only base migrations made are missing; put idempotent SQL for them inseed. Turn your app's ownsynchronizeoff in Karts environments:karts check-configwarns when it is on.
Services
A frontend and an API, or an app and its queue worker, can share one environment. They share the VM's CPU, memory and Postgres, one install and one seed.
runtime: node22
install: npm ci
migrations: db/migrations
seed: npm run seed
env: # shared by every service
NODE_ENV: production
services:
web: # first: served at the environment's own URL
dir: web
build: npm run build # runs after migrations and seed, before any service starts
start: exec npm start
env:
NEXT_PUBLIC_API_URL: ${services.api.url}
API_INTERNAL_URL: ${services.api.internal_url}
api: # served at <env>--api
dir: api
start: exec node server.js
health: /health
jobs:
kind: worker # no port, health or URL
dir: api
start: exec node worker.js
- One to eight services. Names are 1 to 16 lowercase letters, digits and single dashes, starting with a letter.
- The first service is served at the environment's URL; each other web service at
<env>--<name>on the same domain. startis required.diris a directory inside the repository (default: the root).portdefaults to 3000, 3001 and so on in list order; it must be 1024 to 65535, not 5432, and not 6379 whenredis:is on.healthdefaults to/.buildruns after migrations and seed, before any service starts, with the service's variables, including the URLs. Put a build that needs the app's public URL here, not ininstall.- In a service's own
env:,${services.<name>.url}is that service's public HTTPS URL and${services.<name>.internal_url}its address inside the VM. Browsers need the public one, so a browser-facing API must answer CORS for the caller. Server-side code should use the internal one. kind: workermakes a process with no port, health check or URL, such as a queue worker. It cannot be listed first, and it cannot be referenced with${services.…}. It counts as ready once it has kept running for 3 seconds; if it keeps exiting, the revision fails. Its output is inkarts logs, prefixed with its name.- Top-level
startorhealthtogether withservices:is refused.
Static sites
For a single-page app or a static site, Karts serves the built folder itself. You write no server.
runtime: node22 database: none install: npm ci kind: static build: npx ng build # optional; runs before serving dir: dist/app/browser # the built folder to serve spa: true # unknown page paths get index.html
diris the folder to serve, inside the repository. It must exist afterbuild.spa: trueanswers unknown page paths withindex.html, for client-side routing. A missing file such as/assets/app.jsstill gets a 404, so a broken build shows.- A
404.htmlat the top ofdiris used for pages that are not found. Folders are never listed, and files whose names start with a dot are never served. - No
start,healthorsecrets: Karts runs the server and checks it.
As one of several services, beside an API:
services:
web:
kind: static
build: cd web && npm run build # runs at the repository root
dir: web/dist # what is served
spa: true
api:
dir: api
start: exec node server.js
For a static service, build runs at the repository root and dir is what it serves.
Redis
redis: true # Valkey on 127.0.0.1:6379: maxmemory 128mb, policy noeviction # or, to set its two settings: redis: maxmemory: 256mb # 16mb to 512mb; default 128mb policy: allkeys-lru # default noeviction
- Karts runs Valkey 9, which is compatible with Redis 7.2, inside the VM on
127.0.0.1:6379, with no password. It setsREDIS_URL,REDIS_HOSTandREDIS_PORTformigrate,seed, service builds, every service and worker, andkarts exec, but not forinstall. Use${REDIS_URL}inenv:for other names. maxmemorycomes out of the VM's 2 GB. The default policy,noeviction, suits job queues; useallkeys-lrufor a pure cache.- It is not a datastore. Redis is empty at the start of every
karts upand after any restart of its server, so queued jobs, sessions and cached values can be lost at any time. Seed data written to Redis is not kept. Keep durable data in Postgres, and use a client that reconnects. - Changing
redis:in a branch builds that branch's database from scratch, because its migrations and seed may behave differently with Redis.
Links
A frontend in one repository can bring its backend from another Karts project in the same team. Put links: in the frontend's karts.yml:
links:
api:
project: shop-api # the other project's id, or its name in your team
env: API_URL # gets the backend's public URL, for browsers
branch: same-or-main # optional: same-or-main (default), main or same
env:
API_INTERNAL_URL: ${KARTS_LINK_INTERNAL_URL_API} # private path, server side
- On each frontend
karts up, Karts finds the backend's environment.same-or-mainlooks for one on the frontend's branch name, then on the backend's default branch, and starts one from the lastkarts upof the backend's default branch if none is running.sameandmainlook in one place only. The CLI prints which environment it used. - The link's
envvariable gets the backend's public URL, for browsers. For server-side calls,KARTS_LINK_INTERNAL_URL_<NAME>is a private address inside the frontend's VM that reaches that one linked environment and nothing else, without the internet. If the private path is not available, it holds the public URL and the build says why. - Only the URL crosses a link. The frontend never gets the backend's database variables, and each keeps its own Postgres and migrations.
- Linked environments are independent.
karts downof the frontend leaves the backend running and says so. A backend environment started by a link counts against your team's environment limit. - At most 8 links. The URL is fixed into each revision: if the backend is replaced, the frontend gets the new URL on its next
karts up.
Links on your Mac
The same links: block works with karts up --local. A front end in one repository finds its API in another, both on your machine.
- Run
karts up --localin the API's repository first. Karts never starts it for you. - Karts picks the API's local environment on the same branch name, else on its default branch, and prints which one it used.
--link api=feature-xpicks another branch. - Each branch has a URL that stays the same:
http://<branch>.<project>.localhost:7400. So the API can name every local branch of the front end in a committedallow_origins::
allow_origins: - http://*.shop-web.localhost:7400 # every local branch of shop-web
- The API's
KARTS_ALLOWED_ORIGINSthen lists the pattern and the exact origin of every local front end it matches at the API'sup, so exact-match CORS libraries (Expresscors, NestJS) work too. Runkarts up --localin the API again after starting a new front-end branch. See Frontends. - The front end keeps the URL it was given until its next
karts up --local.karts status --localsays when the API was taken down, or when a better match appeared.
Fake outside services
Your app may send email through an API, sign people in with Google or GitHub, or take payments with Stripe. List those services under fakes:, and each branch gets stand-ins that answer like the real ones. They never call the real service, and each one records what your app sent.
mail: true # the test inbox the email fakes deliver to fakes: [sendgrid, google-login, stripe]
sendgrid,resend,postmark,mailgun,gmail: email sent through that service's API. Each message lands in the branch's test inbox, so these needmail: true. Templates are not rendered: the email lists the template and its data.google-login,github-login: “Sign in with Google” and “Sign in with GitHub”. The browser gets a test sign-in page with test users: Alice, Bob and Carol, or your own withusers:.oidc: any other OpenID Connect sign-in, athttps://oidc.karts.test.stripe: customers, payments, a test checkout page, subscriptions and refunds. Stripe's test cards work:4242 4242 4242 4242pays,4000 0000 0000 0002is declined. Webhooks, signed as Stripe signs them, go to yourwebhook_path.
fakes: # one line per fake, with its options
sendgrid: {}
google-login: {users: [alice@example.test, bob@example.test]}
stripe: {webhook_path: /api/stripe/webhook}
stubs: # canned answers for any other API
- host: api.example.com
path: /v1/rates # exact, or a prefix ending in *
method: GET # optional; any method without it
status: 200
body: '{"usd": 1}'
headers: {Content-Type: application/json}
- Your app gets the keys and URLs each fake needs, such as
STRIPE_SECRET_KEYorGOOGLE_CLIENT_ID, instartandbuild. They are test values, and any key is accepted.env:andsecrets:cannot set the same names. - Sign-in. Libraries that look up Google's settings when they start (Auth.js, NextAuth, openid-client) need no change. Libraries with Google or GitHub addresses written in (Passport, OmniAuth, django-allauth, Laravel Socialite, arctic) need one line changed, to a variable Karts sets, such as
$KARTS_GOOGLE_AUTHORIZE_URLor$KARTS_GITHUB_AUTHORIZE_URL. Every GitHub library needs it. Google's One Tap button does not work. - Agents can sign in without a browser, through the test address in
KARTS_GOOGLE_TEST_SIGNIN_URLorKARTS_GITHUB_TEST_SIGNIN_URL. - Stripe. Pay on the test checkout page, or confirm payments from your server with test cards. Declines, 3D Secure checks and refunds work too. Stripe.js and Elements in the browser talk to the real Stripe, so they are not covered. Nothing happens with time: subscriptions do not renew and trials do not end.
- The Stripe fake starts empty at every
up: no products, prices or customers. If your app expects its plans to exist, create them afterup. Run the app's own sync or seed script, such askarts exec --local -- npm run stripe:sync, or a short script with the Stripe SDK:karts exec --local -- node scripts/stripe-seed.js.karts exec --localgives itSTRIPE_SECRET_KEYand the route to the fake, so it creates them in the fake, never in your Stripe account. Prices with alookup_keylet the app find them without hard-coded IDs. - Stubs answer any other API with what you write. The most exact
pathwins. Up to 64 stubs. - A call nothing answers is caught. Your app gets a 502 whose body holds the exact
stubs:lines to add, or the fake to turn on. A call to a host thatinternet:allows goes to the real host instead. karts fakes callslists every call your app made, with keys, passwords, cookies and card numbers removed.karts webhook sendsends your app a signed webhook when you want one. See the CLI.- On your Mac, fakes work with
karts up --localtoo. Your app's outside calls reach them throughHTTPS_PROXY. For Node 18 to 25, Karts also adds a small preload toNODE_OPTIONS, so SDKs that ignoreHTTPS_PROXY, such as stripe-node and anything that usesfetch, reach the fakes with no code change. Your ownNODE_OPTIONSflags are kept.--pass-unmatchedlets calls that no fake or stub answers through to the internet. Production hosts, ports 25 and 53, IP addresses and live keys stay refused, as withinternet:. - Some clients still miss the fakes on your Mac. Go on macOS, stripe-python and stripe-ruby fail with a certificate error, because they do not trust the fakes. Node code that opens its own TLS socket, or makes its own undici client, goes straight to the internet. Point any of these at
$KARTS_FAKES_API_BASE, a plain-HTTP address on your Mac, followed by the real host. For example, in Python:stripe.api_base = os.environ["KARTS_FAKES_API_BASE"] + "/api.stripe.com".karts up --locallists these clients.
Internet access
Without internet:, a hosted app reaches nothing on the internet. With it, the running app, every service and karts exec can reach the hosts it allows. Install, migrate, seed and build never get it. Each connection leaves through an exit server that Karts runs, and Karts logs it.
internet: presets: [openai, plaid-sandbox] # named API and sandbox hosts hosts: [api.mapbox.com, "*.algolia.net", "db.example.net:5432"]
internet: full: true # every public host, every TCP port but 25 and 53
| Key | What it does |
|---|---|
presets | Named sets of hosts, on port 443 (below). At most 32. An unknown name is refused with the list. |
hosts | Other hosts, at most 64: api.example.com (port 443), db.example.net:5432 (another port), or "*.example.com", which matches every name under example.com but not example.com itself. |
full | true: every public host, on every TCP port but 25 and 53. Not with presets or hosts. |
allow_production | plaid, quickbooks or stripe: the branch may reach that provider's production, once a team owner approves. See Production hosts and live keys. |
internet: offis the same as leaving the key out.internet: onortrueis refused: write the block. A block needspresets,hostsorfull: true.hosts:takes a host name, not a URL: no scheme, path or IP address, no bare*, and no*.right before a top-level domain such as"*.com". Names under.localhost,.local,.internal,.test,.example,.invalid,.home.arpaand.onionare refused: they never reach the internet.- Karts refuses a preset or host that one of your fakes answers, such as
stripe-testwithfakes: [stripe]. The fake would always win, so remove one of them. - A change takes effect at the next
karts up. A running revision keeps the rules it started with. - The rules cover the whole VM: every service gets the same internet.
- Only TCP to host names, over IPv4. UDP is refused at once, so QUIC falls back to TCP. Only A records resolve, so
mongodb+srv://and other SRV-based connection strings fail; list the hosts instead (mongodb://host1,host2/).
Presets
Each preset is port 443 only. The build log prints each preset's note.
| Preset | Hosts | Note |
|---|---|---|
openai | api.openai.com | No sandbox: every call is billed to the key's project. |
anthropic | api.anthropic.com | No sandbox: every call is billed to the key's workspace. |
plaid-sandbox | sandbox.plaid.com | Sandbox only. production.plaid.com is refused. |
quickbooks-sandbox | sandbox-quickbooks.api.intuit.com, sandbox-accounts.platform.intuit.com, oauth.platform.intuit.com, developer.api.intuit.com | Sandbox companies only. quickbooks.api.intuit.com is refused. The OAuth token host is shared with production, so use Development keys. |
stripe-test | api.stripe.com, files.stripe.com, connect.stripe.com | Stripe's real test mode. Test and live share these hosts, so live keys are refused. The stripe fake is the default; the two cannot be on together. |
hubspot | api.hubapi.com, api-eu1.hubapi.com | Not guarded: test accounts and production portals share these hosts. Use a developer test account's token. |
nango | api.nango.dev | Not guarded: dev and prod share this host, and the secret key decides. Use the dev key. |
browserbase | api.browserbase.com, connect.browserbase.com | No sandbox: every session is real browsing, and billed. Use a dev project. |
slack | slack.com, wss-primary.slack.com, wss-backup.slack.com | Not guarded: the token's workspace decides. Use a developer sandbox workspace. |
aws | every *.amazonaws.com host | Broad, and not guarded: access keys do not say which account is production. Use a dev account. |
Production hosts and live keys
- Four production hosts are refused in every mode,
full: trueincluded: production.plaid.com, development.plaid.com (retired), quickbooks.api.intuit.com and accounts.platform.intuit.com. Listing one inhosts:is refused, with the sandbox preset to use instead. A*.entry never matches one. - To reach one, add its provider to
allow_production:, and have a team owner runkarts egress allow-production plaid. Editingkarts.ymlalone is not enough: without an approval in force,karts upis refused (approval_needed) and names the command.
internet: presets: [plaid-sandbox] allow_production: [plaid] # and a team owner's approval
- An approval is for one project and one provider. It lasts 7 days, or what the owner sets with
--for, from 1 hour to 30 days.karts egress approvalslists them. - When an approval is removed (
--remove) or expires, running branches lose that production at once: open connections close and new ones are refused (approval_revoked). Forstripe, that covers every stripe.com host. A Karts host cut off from Karts' control plane for 15 minutes treats its approvals as removed. - Every
karts upwith an approval in force warns that the branch can reach PRODUCTION, and says who approved it and until when. - Live keys. With
internet:on, a live Stripe secret or restricted key (sk_live_…,rk_live_…) refuses the up (live_key). Karts looks in the secrets listed insecrets:, inenv:(top level and each service's), and in.envfiles at the repository root and in each service'sdir. The message names the place and the variable, never the value. Use test keys (sk_test_…). A live key needsallow_production: [stripe]and an owner's approval. - The scan reads
.env,.env.local,.env.productionand the like, but not.example,.sample,.templateor.distfiles. More than 32 of them, or one over 256 KiB, refuses the up: add the ones the app does not need to.gitignore. A publishable key (pk_live_…) is only a warning. - The scan finds keys written out in full. A key the app builds, encodes or fetches while it runs is not seen.
Always refused
In every mode, full: true included:
- Port 25 (SMTP) and port 53 (DNS). Send mail through your provider's HTTPS API, or its port 465 or 587, which are rate-limited (below). Names resolve inside the VM.
- IP addresses. Connect by name.
- Names that resolve to a private or reserved address, such as a 10.x address or cloud metadata (
private_address). Karts checks the address it connects to, not only the name. - Karts' own domains (
karts_internal). An app reaches another environment throughlinks:, not the internet. - The production hosts above, unless approved, and hosts the Karts operator has blocked (
operator_denied). - A TLS connection that names a server other than the host it connects to, names none, or hides the name with Encrypted Client Hello. Karts reads the name the client sends; it never decrypts the traffic.
Limits
| Limit | Per environment | Per team |
|---|---|---|
| Transfer per UTC day | 2 GiB | 10 GiB |
| Open connections | 256 | 1,024 |
| New connections | 20 a second, bursts of 200 | not limited |
| Hosts and ports | 500 an hour | 2,000 an hour |
| Bandwidth | 20 Mbit/s | 50 Mbit/s |
| Mail connections | 30 an hour | 100 an hour |
Transfer counts both directions; bandwidth is for each direction. Hosts and ports counts each different host and port the app connects to. Mail connections are new connections to ports 465 and 587. An environment's limits cover all its revisions. A connection closes after 15 minutes with no traffic, and after 24 hours in any case.
The log, refusals and the off switch
karts egress loglists each connection and each refusal: the time, the host and port, the result and the bytes each way. It never holds what was sent or received. Past a daily size, or in a flood of refusals, Karts sums them per hour or per minute instead. Records are kept for up to 30 days, also for environments that are gone: name one by its old name. See the CLI.- A refused call reaches the app as a 502 on ports 443 and 80, whose JSON says why and what to change, such as the
hosts:entry or preset to add. On other ports the connection closes. Refusals also show inkarts fakes calls. - A team owner turns the team's internet off with
karts egress off: new connections are refused and open ones close, on every Karts host.karts egress onturns it back on. If Karts suspends a team's internet, owners cannot turn it back on.
When karts up refuses it
Besides the rules above, karts up refuses internet: before any VM starts when:
- the CLI is older than 0.7.0. It does not tell Karts that the branch asks for the internet, so the host refuses the up (
internet_unchecked). Update the CLI and runkarts upagain; - the Karts host has no internet gateway, as on an air-gapped install (
no_gateway); - the host's runtime image predates the internet relay (
image_too_old); - a
hosts:entry names one of Karts' own domains (karts_internal).
On your Mac
karts up --localapplies the same rules from your own network: the same presets and hosts, and the same refusals of production hosts, ports 25 and 53, IP addresses, private addresses and live keys. There,allow_production:inkarts.ymlis enough: no approval is asked.karts egress log --localreads the records, which stay on your machine.- Calls reach the rules through
HTTPS_PROXYand the Node preload, as with fakes. A client that ignores both goes straight to the internet.
Recordings
Record the app's calls to a sandbox API once, then replay them for free, without the internet. An LLM call with a fixed prompt replays the same answer every time.
internet: presets: [anthropic] # record and auto call the real host recordings: mode: auto # replay a recorded call, record a new one hosts: [api.anthropic.com] ignore: [body.metadata.user_id] # leave this out of the match match_headers: [anthropic-version] # and match on this header too
| Key | Default | What it does |
|---|---|---|
hosts | required | The hosts whose calls are recorded and replayed: 1 to 16 exact names, no *. |
mode | replay | replay answers from recordings and never calls the real host. record calls the real host every time and records the answer. auto replays a call it has, and calls and records one it has not. |
ignore | none | Parts of a request left out of the match, such as timestamps and random ids: body.a.b (* for any member or array element) or query.name. At most 64. |
match_headers | none | Headers that must match too, such as anthropic-version. At most 16. No other header counts. |
binary | false | true also stores binary bodies, such as images and PDFs. In those, Karts can mask only your secrets' exact values. |
recordandautocall the real host, sointernet:must allow each host on port 443, or the up is refused.replayneeds no internet.- A recorded host cannot be a production host, a host that a fake you turned on answers, or a host in
mocks:. - Matching. A call matches on its method, host, path, query, body and
match_headers, after redaction andignore. When the same call is made several times, its recordings replay in order, then the last one repeats. - A miss. In
replay, a call with no recording gets a 502 (karts_recording_miss) that lists the closest recordings and the first part that differs. Add that part toignore, or record again withmode: auto. - What is stored. Before a call leaves the VM, Karts removes the values of your secrets, and the keys, tokens, passwords, cookies and card numbers it recognises by name or by shape. A replayed answer has
‹redacted›where they were. Cookies the API sets are not replayed. - Not stored: requests over 1 MiB, answers over 4 MiB, bodies in an encoding Karts cannot read, and binary bodies unless
binary: true.recordandautostill pass such a call through;replaytreats it as a miss that says why. - Live keys. A call carrying a live Stripe secret or restricted key is refused, unless
allow_production: [stripe]is approved. Thenrecordandautopass it through without recording it, andreplaytreats it as a miss. - Where they are kept. On the Karts host, per project, encrypted. The default branch records into the project's shared set, and every other branch into its own. A branch replays its own recordings first, then the shared set. At most 256 MiB per project, and 64 MiB per host in a set; past that, the recordings stored longest ago go first.
karts recordings list,rmandpromoteshow, delete and share them:promote --branch Bmakes a branch's recordings the shared set's. See the CLI.- WebSocket upgrades are refused. A client that trusts only its own list of certificates cannot be recorded: it fails with a certificate error.
- On your Mac,
karts up --localrecords and replays the same way, into one set per project on your machine.
OpenAPI mocks
For an API with an OpenAPI spec, a mock answers in the shapes the spec documents, with no account and no internet.
mocks:
- host: api.vendor.example
spec: openapi/vendor.yaml # an OpenAPI 3 file in the repository
state: crud # optional: what the app creates can be read back
validate: true # optional: 400 for requests the spec refuses
- host: api.openai.com
spec: catalog:openai # pinned by Karts: catalog:openai or catalog:stripe
| Key | What it does |
|---|---|
host | Required. The host the mock answers, such as api.vendor.example: a name only, with no scheme, port or *. |
spec | Required. An OpenAPI 3.0 or 3.1 file in the repository, or a spec Karts pins: catalog:openai or catalog:stripe. |
state | crud: what the app creates can be read, changed and deleted again, in memory, until the VM goes. Without it, nothing is kept. |
validate | true: a request the spec does not allow gets a 400 that says why. Default false. |
- An answer is the operation's documented success response: its example, or values made to fit its schema. The same request gets the same answer. A request can ask for another documented response with
Prefer: code=404orPrefer: example=<name>. - Every answer is checked against the spec before it is sent. An operation Karts cannot answer correctly gets a 501 (
karts_mock_unsupported), andkarts uplists those operations. - A path or method the spec does not document is not the mock's. It gets the 502 that says what to add, or, in the cloud, goes to the real host if
internet:allows it. - At most 16 mocks, 16 MiB per spec and 32 MiB in all. Swagger 2.0 is refused: convert it to OpenAPI 3 first. A spec cannot refer to other files or URLs.
state: crudneeds a collection in the spec: a path such as/itemswith aPOSTand a listGET, and/items/{id}with aGET. A spec with none is refused. At most 10,000 objects and 16 MiB per mock.- The catalog specs are OpenAI's and Stripe's published specs, pinned to one version. The Karts host downloads one the first time a branch uses it and keeps a copy. If it cannot, the up fails and says to commit the spec instead.
mocks:needs nointernet:. - A host cannot be both mocked and recorded, or mocked when a fake you turned on answers it. A stub on the same host answers its own paths first.
- On your Mac,
karts up --localruns the same mocks.
Which one answers a call
When the app calls a host, the first of these that covers the call answers it:
- a fake you turned on that owns the host (some fakes own only some of its paths);
- a fake's paths on a host it shares, such as the Gmail API's paths on
www.googleapis.com; - a stub that matches;
- the host's recordings;
- the host's mock, for the paths and methods its spec documents;
- the real host, when
internet:allows it; - otherwise, on a host you stub, a 404 that lists its stubs; on any other host, a 502 that says what to add.
So on a host you stub, the paths no stub matches still reach the real host when internet: allows it. On your Mac, step 6 applies only to hosts that steps 1 to 5 do not cover at all.
MySQL and SQLite
database: mysql # or sqlite; postgres is the default
migrations: db/migrations # numbered .sql files, in MySQL's syntax
env:
DB_HOST: ${MYSQL_HOST}
DB_PORT: ${MYSQL_PORT}
DB_DATABASE: ${MYSQL_DATABASE}
DB_USERNAME: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
database: mysqlruns MySQL 8.4 inside the VM.database: sqlitegives the app a SQLite file of its own. Postgres stays the default.- Each branch gets its own copy, built from the template the same way as Postgres. Numbered SQL migrations, number claims and the destructive check work on both.
- Map your app's own settings to the variables above, as in the example.
Secrets
A secret is a value your app needs that must not be in the repository: an API key, a password. Set it with the CLI, and list its name in karts.yml:
echo -n "$KEY" | karts secret set STRIPE_KEY # every branch echo -n "$TEST_KEY" | karts secret set STRIPE_KEY --branch feature/refunds
secrets: [STRIPE_KEY, SENTRY_DSN] # the app and every service get these
secrets_in: [migrate, seed] # optional: these steps get them too
services:
web:
start: exec npm start
api:
dir: api
start: exec node server.js
secrets: [OAUTH_CLIENT_SECRET] # this service only
karts secret setreads the value from stdin, or asks for it without showing it. A value can be for the whole project (the default), one branch (--branch) or one environment (--env). The most specific one wins.- Top-level
secrets:reach the app and every service. A service's ownsecrets:reach only that service.installand servicebuildsteps never get secrets;migrateandseedget them only withsecrets_in. - Values are stored encrypted. Karts' logs and
karts execoutput show‹secret›in place of a value. - A change takes effect at the next
karts up. - Anyone who can run
karts execin the environment can read its secrets. Environment URLs are still public, so use keys you can afford to lose: test keys, not production ones.
Masked copies of real data
Seed data is often too thin to test with. karts db snapshot makes a copy of your real data that is safe to use:
karts db snapshot --check # see what it will do to each column karts db snapshot # read, mask, write the file karts up --snapshot FILE # or set seed_snapshot: in karts.yml
seed_snapshot: .karts/snapshots/prod.ksnap # a file karts db snapshot wrote
masking: # read only by karts db snapshot
exclude: [audit_log, sessions] # never copied
subset:
orders: {limit: 1000, order_by: created_at desc}
columns:
users.email: email # a fake email
users.ssn: hash
products.name: keep
- It runs on your machine. It reads your Postgres database (a replica is best) in one read-only transaction, so it changes nothing there. Set the source with
--from URLorKARTS_SNAPSHOT_FROM. - It replaces personal data with fakes. Rows that point at each other still do, so joins and foreign keys keep working.
- The raw data never leaves your machine. Only the masked file is uploaded, and Karts stores it encrypted.
- Branches load it in place of
seed, withseed_snapshot:orkarts up --snapshot FILE.--no-snapshotignores it. - Text it has no rule for is masked. A column you
keepis still scanned, and the run stops at a value that looks like an email, phone or card number. Keeping personal data on purpose (keep_pii) needs--allow-kept-pii. Check the plan with--checkfirst.
Variables in env
env:
DB_HOST: ${PGHOST}
DB_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/${PGDATABASE}
CELERY_BROKER_URL: ${REDIS_URL}/1 # with redis: on
PRICE: $$5
${NAME}takes a variable Karts sets (DATABASE_URL,PG*,PORT,KARTS_*, andREDIS_*whenredis:is on), alinks:variable, or another key of the sameenv:. A service's ownenv:can also use${services.<name>.url}.$$is a literal$. Any other$stays as written, so$HOMEin a value is not expanded.- An unknown name is refused with the line. A key that names another key whose value names a third is refused: no chains.
- In the template build there is no environment yet, so
${KARTS_URL…}and links' variables are empty there.
Which karts.yml is used
The template database is built from the base commit (where your branch left the default branch, or the base: branch), with that commit's karts.yml, migrations and seed. Everything else (runtime, install, start, health, services, env) comes from your branch. If your branch changes database, migrations, migrate, migrations_layout, migrations_glob, seed, redis, mail or seed_fail_on, Karts builds its database from scratch instead of cloning the template.
So the template needs karts.yml on the default branch. Until it is merged there, every karts up warns "the base has no karts.yml; building from scratch" and runs every migration and the seed each time.