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

KeyDefaultWhat it does
projectnoneThe project this repository belongs to. Written by karts init. karts up refuses to run without it.
runtimenode22The runtime image: node22, node24, python312, python314, go, ruby33 or php83. Any other name is refused with the list. What each image holds: The VM.
databasepostgresThe 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.
migrationsnoneA directory inside the repository holding numbered .sql files. Karts checks and applies them itself. With migrations_layout, the directory that layout reads.
migratenoneYour 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.
schemanoneFor 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_layoutkartsHow 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_globthe layout'sWhich 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.
installnoneInstalls dependencies, for example npm ci. It gets no env: and no database.
seednoneLoads demo data. Runs once, when the template database is built from the default branch.
seed_fail_onnoneerror: 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_snapshotnoneA 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.
maskingnoneRules for karts db snapshot: which tables to copy and how to mask each column. Nothing else reads them.
basenoneThe branch your branches start from, if it is not the default branch, for example develop. Its migrations and seed build the template. --base wins.
startrequiredStarts 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.
kindnonestatic: Karts serves a built folder itself, with no start or health. See Static sites.
build, dir, spanoneWith kind: static only: the command that builds the site, the folder to serve (required), and spa: true for single-page app routing.
envnonePlain, 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.
secretsnoneNames 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_innone[migrate, seed], or one of them: those steps get the top-level secrets too.
servicesnoneInstead 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.
redisofftrue runs Valkey, a Redis-compatible server, inside the VM and sets REDIS_URL. See Redis.
mailofftrue 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.
fakesnoneStand-ins for outside services: email APIs, Google and GitHub sign-in, Stripe. See Fake outside services.
stubsnoneCanned answers for any other outside API. See Fake outside services.
internetoffWhat the running app may reach on the internet: named presets, hosts you list, or full: true. Build steps never get it. See Internet access.
recordingsnoneRecords the app's calls to the hosts you list, then replays them without the internet. See Recordings.
mocksnoneAnswers a host from its OpenAPI spec. See OpenAPI mocks.
linksnoneOther 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_originsnoneBrowser 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: value lines 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 own env: 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 - item lines or one [a, b] line. fakes: can also be two-space name: {option: value} lines, and each stubs: or mocks: entry is a - host: line followed by four-space keys. Under internet: and recordings:, 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 set DATABASE_URL, PORT, the MySQL and SQLite names above, or anything starting with PG or KARTS_. With redis: on, it cannot set REDIS_URL, REDIS_HOST or REDIS_PORT either. Karts sets those.

What your app gets

VariableValue
PORTThe 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 secretsOnly the names in secrets:, with the values set by karts secret set.
REDIS_URL
REDIS_HOST, REDIS_PORT
Only with redis:: redis://127.0.0.1:6379.
KARTS_SERVICE, KARTS_URLOnly 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_URLOnly with mail:: 127.0.0.1, 1025 and smtp://127.0.0.1:1025, the test inbox.
The fakes' keys and URLsOnly 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_ORIGINSOnly 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:

StepRuns inGets
installrepository rootNo env:, no database, no PORT. The registry settings (network).
migrate, seedrepository rootenv:, the database, Redis; the registry settings.
service buildthe service's dirEverything its start gets except PORT; the registry settings.
start, one servicerepository rootenv:, the database, Redis, links' variables, PORT, KARTS_ALLOWED_ORIGINS.
service startthe service's dirThe 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>.sql or <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 0011 and 11 are the same number.
  • Karts applies each file in its own transaction. A file whose first line is -- karts:no-transaction runs 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 migrate after install. Each revision runs your branch's migrate after install, so a branch's new migration is applied on a plain karts 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.
  • prisma and golang-migrate follow the numbered rules above, and their numbers are claimed. Without migrate, Karts applies them itself: Prisma's NN_name/migration.sql folders and golang-migrate's .up.sql files, never .down.sql. Timestamp layouts (knex, rails and 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) need migrate. Use files with migrations_glob for 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 migrate runs and refuses dropped schemas, tables and columns, narrowed column types and emptied tables unless you pass --allow-destructive. It cannot see UPDATEs, 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. Needs migrations_layout: typeorm, and no migrate. TypeORM 0.3.
  • prisma: Karts runs prisma db push for the base, and applies each new migration.sql itself. Needs migrations_layout: prisma, and no migrate. Prisma 7.
  • custom: any other tool, through your own commands. Karts runs them but does not check what they do, so it needs unverified: true.
  • Another TypeORM or Prisma version fails after install, before sync, and says to use custom.
KeyForWhat it does
fromalltypeorm, prisma or custom. Required.
dirtypeorm, prismaWhere the tool runs, relative to the repository root. Default: the root.
datasourcetypeormThe data source file typeorm -d takes, relative to dir. Required. It may be a build output, such as dist/data-source.js.
clitypeormtypeorm, 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.
ledgertypeorm, customThe table your tool records migrations in, as name or schema.name. TypeORM's default is migrations. Required for custom with migrate.
prisma_schema, prisma_configprismaThe schema and config files every Prisma step uses, relative to dir. Defaults: prisma/schema.prisma and prisma.config.ts.
on_driftallwarn (default) or fail: what a branch's model changes that no migration makes do (below).
unverifiedcustomMust be true.
synccustomA command that builds the schema from your models in an empty database. Required.
baselinecustomA 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).
driftcustomA 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 follows on_drift: a warning for each statement, or a failure.
  • --schema-from-branch (karts up and karts 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. up warns that no migration ran.
  • What Karts checks. With typeorm and prisma: 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. With custom, 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 custom it cannot check.
  • Not with MySQL, SQLite, database: none or secrets_in. Views, functions, triggers and rows that only base migrations made are missing; put idempotent SQL for them in seed. Turn your app's own synchronize off in Karts environments: karts check-config warns 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.
  • start is required. dir is a directory inside the repository (default: the root). port defaults to 3000, 3001 and so on in list order; it must be 1024 to 65535, not 5432, and not 6379 when redis: is on. health defaults to /.
  • build runs 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 in install.
  • 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: worker makes 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 in karts logs, prefixed with its name.
  • Top-level start or health together with services: 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
  • dir is the folder to serve, inside the repository. It must exist after build.
  • spa: true answers unknown page paths with index.html, for client-side routing. A missing file such as /assets/app.js still gets a 404, so a broken build shows.
  • A 404.html at the top of dir is used for pages that are not found. Folders are never listed, and files whose names start with a dot are never served.
  • No start, health or secrets: 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 sets REDIS_URL, REDIS_HOST and REDIS_PORT for migrate, seed, service builds, every service and worker, and karts exec, but not for install. Use ${REDIS_URL} in env: for other names.
  • maxmemory comes out of the VM's 2 GB. The default policy, noeviction, suits job queues; use allkeys-lru for a pure cache.
  • It is not a datastore. Redis is empty at the start of every karts up and 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.

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-main looks for one on the frontend's branch name, then on the backend's default branch, and starts one from the last karts up of the backend's default branch if none is running. same and main look in one place only. The CLI prints which environment it used.
  • The link's env variable 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 down of 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.

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 --local in 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-x picks 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 committed allow_origins::
allow_origins:
  - http://*.shop-web.localhost:7400   # every local branch of shop-web
  • The API's KARTS_ALLOWED_ORIGINS then lists the pattern and the exact origin of every local front end it matches at the API's up, so exact-match CORS libraries (Express cors, NestJS) work too. Run karts up --local in 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 --local says 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 need mail: 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 with users:.
  • oidc: any other OpenID Connect sign-in, at https://oidc.karts.test.
  • stripe: customers, payments, a test checkout page, subscriptions and refunds. Stripe's test cards work: 4242 4242 4242 4242 pays, 4000 0000 0000 0002 is declined. Webhooks, signed as Stripe signs them, go to your webhook_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_KEY or GOOGLE_CLIENT_ID, in start and build. They are test values, and any key is accepted. env: and secrets: 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_URL or $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_URL or KARTS_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 after up. Run the app's own sync or seed script, such as karts exec --local -- npm run stripe:sync, or a short script with the Stripe SDK: karts exec --local -- node scripts/stripe-seed.js. karts exec --local gives it STRIPE_SECRET_KEY and the route to the fake, so it creates them in the fake, never in your Stripe account. Prices with a lookup_key let the app find them without hard-coded IDs.
  • Stubs answer any other API with what you write. The most exact path wins. 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 that internet: allows goes to the real host instead.
  • karts fakes calls lists every call your app made, with keys, passwords, cookies and card numbers removed. karts webhook send sends your app a signed webhook when you want one. See the CLI.
  • On your Mac, fakes work with karts up --local too. Your app's outside calls reach them through HTTPS_PROXY. For Node 18 to 25, Karts also adds a small preload to NODE_OPTIONS, so SDKs that ignore HTTPS_PROXY, such as stripe-node and anything that uses fetch, reach the fakes with no code change. Your own NODE_OPTIONS flags are kept. --pass-unmatched lets 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 with internet:.
  • 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 --local lists 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
KeyWhat it does
presetsNamed sets of hosts, on port 443 (below). At most 32. An unknown name is refused with the list.
hostsOther 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.
fulltrue: every public host, on every TCP port but 25 and 53. Not with presets or hosts.
allow_productionplaid, quickbooks or stripe: the branch may reach that provider's production, once a team owner approves. See Production hosts and live keys.
  • internet: off is the same as leaving the key out. internet: on or true is refused: write the block. A block needs presets, hosts or full: 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.arpa and .onion are refused: they never reach the internet.
  • Karts refuses a preset or host that one of your fakes answers, such as stripe-test with fakes: [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.

PresetHostsNote
openaiapi.openai.comNo sandbox: every call is billed to the key's project.
anthropicapi.anthropic.comNo sandbox: every call is billed to the key's workspace.
plaid-sandboxsandbox.plaid.comSandbox only. production.plaid.com is refused.
quickbooks-sandboxsandbox-quickbooks.api.intuit.com, sandbox-accounts.platform.intuit.com, oauth.platform.intuit.com, developer.api.intuit.comSandbox companies only. quickbooks.api.intuit.com is refused. The OAuth token host is shared with production, so use Development keys.
stripe-testapi.stripe.com, files.stripe.com, connect.stripe.comStripe'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.
hubspotapi.hubapi.com, api-eu1.hubapi.comNot guarded: test accounts and production portals share these hosts. Use a developer test account's token.
nangoapi.nango.devNot guarded: dev and prod share this host, and the secret key decides. Use the dev key.
browserbaseapi.browserbase.com, connect.browserbase.comNo sandbox: every session is real browsing, and billed. Use a dev project.
slackslack.com, wss-primary.slack.com, wss-backup.slack.comNot guarded: the token's workspace decides. Use a developer sandbox workspace.
awsevery *.amazonaws.com hostBroad, 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: true included: production.plaid.com, development.plaid.com (retired), quickbooks.api.intuit.com and accounts.platform.intuit.com. Listing one in hosts: 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 run karts egress allow-production plaid. Editing karts.yml alone is not enough: without an approval in force, karts up is 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 approvals lists 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). For stripe, 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 up with 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 in secrets:, in env: (top level and each service's), and in .env files at the repository root and in each service's dir. The message names the place and the variable, never the value. Use test keys (sk_test_…). A live key needs allow_production: [stripe] and an owner's approval.
  • The scan reads .env, .env.local, .env.production and the like, but not .example, .sample, .template or .dist files. 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 through links:, 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

LimitPer environmentPer team
Transfer per UTC day2 GiB10 GiB
Open connections2561,024
New connections20 a second, bursts of 200not limited
Hosts and ports500 an hour2,000 an hour
Bandwidth20 Mbit/s50 Mbit/s
Mail connections30 an hour100 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 log lists 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 in karts 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 on turns 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 run karts up again;
  • 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 --local applies 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: in karts.yml is enough: no approval is asked.
  • karts egress log --local reads the records, which stay on your machine.
  • Calls reach the rules through HTTPS_PROXY and 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
KeyDefaultWhat it does
hostsrequiredThe hosts whose calls are recorded and replayed: 1 to 16 exact names, no *.
modereplayreplay 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.
ignorenoneParts 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_headersnoneHeaders that must match too, such as anthropic-version. At most 16. No other header counts.
binaryfalsetrue also stores binary bodies, such as images and PDFs. In those, Karts can mask only your secrets' exact values.
  • record and auto call the real host, so internet: must allow each host on port 443, or the up is refused. replay needs 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 and ignore. 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 to ignore, or record again with mode: 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. record and auto still pass such a call through; replay treats 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. Then record and auto pass it through without recording it, and replay treats 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, rm and promote show, delete and share them: promote --branch B makes 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 --local records 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
KeyWhat it does
hostRequired. The host the mock answers, such as api.vendor.example: a name only, with no scheme, port or *.
specRequired. An OpenAPI 3.0 or 3.1 file in the repository, or a spec Karts pins: catalog:openai or catalog:stripe.
statecrud: what the app creates can be read, changed and deleted again, in memory, until the VM goes. Without it, nothing is kept.
validatetrue: 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=404 or Prefer: 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), and karts up lists 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: crud needs a collection in the spec: a path such as /items with a POST and a list GET, and /items/{id} with a GET. 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 no internet:.
  • 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 --local runs the same mocks.

Which one answers a call

When the app calls a host, the first of these that covers the call answers it:

  1. a fake you turned on that owns the host (some fakes own only some of its paths);
  2. a fake's paths on a host it shares, such as the Gmail API's paths on www.googleapis.com;
  3. a stub that matches;
  4. the host's recordings;
  5. the host's mock, for the paths and methods its spec documents;
  6. the real host, when internet: allows it;
  7. 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: mysql runs MySQL 8.4 inside the VM. database: sqlite gives 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 set reads 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 own secrets: reach only that service. install and service build steps never get secrets; migrate and seed get them only with secrets_in.
  • Values are stored encrypted. Karts' logs and karts exec output show ‹secret› in place of a value.
  • A change takes effect at the next karts up.
  • Anyone who can run karts exec in 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 URL or KARTS_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, with seed_snapshot: or karts up --snapshot FILE. --no-snapshot ignores it.
  • Text it has no rule for is masked. A column you keep is 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 --check first.

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_*, and REDIS_* when redis: is on), a links: variable, or another key of the same env:. A service's own env: can also use ${services.<name>.url}.
  • $$ is a literal $. Any other $ stays as written, so $HOME in 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.