Reference · the environment directory
The environment directory
An environment is a directory that Docker runs. Everything Docker needs and everything you change lives in ~/environments/<name>/, and a template is a copy of one. This page is the contract: every file, when Bettik runs it, and what it finds in its environment variables.
The files
bettik.json- The contract. Which service is the app and on what port; which secrets to mint; what
.envshould say; how many seconds each script gets. Read by Bettik; never run. compose.yaml- The setup: the app's runtime and the services beside it. Docker Compose and only Docker Compose — yours to edit. Every
${VARIABLE}in it is supplied by Bettik. Applied bybuild_environment. seed- Runs once, when the environment is installed, inside the app container. Make the directory yours: clone or create the project, create schema, load fixtures, write config. From a saved environment it also adopts: it gets
OLD_*beside the fresh secrets and rotates what it knows how to. Exit non-zero and the install fails, with the last lines as the reason. update- Bring a saved environment current: pull, reinstall, migrate. Runs after
seedwhen the environment was installed from a saved environment, and wheneverupdate_environmentis called. Must be safe to run twice. start- Runs on every start, in the app container, and stays in the foreground: start the processes your work needs. The container runs it again whenever it exits, so an environment with nothing to run yet idles rather than crash-looping, and starts the moment
starthas something to start. It runs in a process group of its own, so atrap "kill 0" EXITin it stops what it started and not the container (compose.yamlis the environment's own and Bettik never rewrites it, so an older environment keeps the loop it was made with: copy the starter kit'scommandto have this). No timeout. stop- Optional. A graceful stop, with thirty seconds. Without it the containers are simply stopped, which is fine for a dev server.
.env- What Bettik knows, written on every start: the environment's name and address, every port, every secret, and the
envmap frombettik.jsonexpanded. Edits do not survive. The only place the credentials are. projects/- The code. One or more repositories, created or cloned in. An environment may put things here when they are part of its setup (a WordPress checkout) and does not when they are not.
CLAUDE.md- The guide a session reads when it enters the environment. Seeded once, yours after that.
README-bettik.md- What the environment says about itself.
.bettik/- Bettik's own notes: where the environment came from (
origin.json), and for an environment installed from a template the secrets its data was made with, mode 0600, removed once read.
The scripts are free-form executables. Each ships as a commented skeleton stating its own contract and a worked example for that environment.
The one rule
Whatever you do by hand that a fresh copy would also need, put in seed or update. Cloned a repository? The git clone line goes in seed. Created a schema? The migration goes in seed, and its re-run in update. Changed the setup? That is already in compose.yaml. This is what makes save_environment and publish_template produce a template version that works when installed, and it costs one line at the time you did the thing.
bettik.json
{
"version": 3,
"description": "Node 22 for the app and Postgres 17 beside it.",
"app": { "service": "web", "port": 3000 },
"urls": {},
"ports": [],
"secrets": ["DB_PASSWORD"],
"env": {
"DATABASE_URL": "postgresql://app:${DB_PASSWORD}@${_BETTIK_ENVIRONMENT}-db:5432/app"
},
"summary": {
"runtime": "Node 22 (node:22-trixie-slim), listening on $PORT",
"services": ["Postgres 17 at <environment>-db:5432, database app, role app"],
"projects": "empty -- create a project or clone one in, then start it from start"
},
"timeouts": { "seed": 1800, "update": 1800, "stop": 30 }
}
app- Which compose service the environment's hostname routes to, and on what port inside the container. The scripts run in this container too.
requires- What the environment needs from whoever installs it: env vars from the settings page, by name, each with a
description, and optionally adefaultor"optional": true. Handed toseed,updateandstopand nothing else of theirs;"env": truealso writes it to.env. An environment needing one that is not set refuses to install, and says which. open- Beside
appor aurlsentry: paths on a private environment that anyone may reach --["/webhook", "/hooks/*"]. See leaving a path open. urls- Every other hostname the environment answers on.
"api"isapi-<name>.home.<handle>.bettik.dev, routed to a service and port. Each gets a host port of its own. widget- A small view of the environment drawn on its card --
{ "url": "monitor", "path": "/widget.html", "height": 64 }. See a widget on its card. ports- Host ports with no hostname — a debugger, something to tunnel to. Names only; Bettik allocates the numbers.
grants- What the environment may ask its box to do to itself, with no Claude in the way:
snapshot(save itself),prune(delete its own older saved environments) andgateway(post to these topics,"topics": ["spaces"]). Each with adescriptionthat the template page shows before anyone installs it. See grants. secrets- Names. Bettik mints a random value for each, per environment, and keeps it for the environment's life. A name added later is minted on the next build.
env- What
.envshould say beyond theBETTIK_*variables and the secrets, with${VAR}expanded from both. The app container gets these too. summary- What a person is getting, said plainly. This is what
list_templatesandinstall_environmentreport. timeouts- Seconds before Bettik gives up on a script.
starthas none.
The variables
Supplied to compose.yaml, written to .env, and in the process environment of every script. Never write a port, a path or a password into compose.yaml yourself: they are allocated per environment and would collide with the next one.
The leading underscore marks a name as Bettik's, so it can never clash with one of yours. The names from before it (BETTIK_STACK, BETTIK_PORT and so on) are no longer set, so an environment that still uses one gets an empty value. build_environment and start_environment say which of compose.yaml, bettik.json and the scripts still use one and what it is called now, and save_environment refuses an environment that does.
_BETTIK_ENVIRONMENT- The environment's name:
catan. _BETTIK_OWNER- The box:
<box>. Pass it asboxfrom a session on the box. _BETTIK_APP_HOST- The environment's hostname:
catan.home.<handle>.bettik.dev. Frameworks treat a proxied hostname as a cross-origin attack unless told: NextallowedDevOrigins, Viteserver.allowedHosts, DjangoALLOWED_HOSTS, Railsconfig.hosts. _BETTIK_APP_URL- The same, as
https://…. _BETTIK_PORT- The host port behind the environment's hostname. In
compose.yaml:"127.0.0.1:${_BETTIK_PORT}:3000". Inside the container the app listens on its own port —$PORT, 3000 — and Bettik never changes that. _BETTIK_PORT_<NAME>- One per entry in
urlsandports, uppercased, hyphens to underscores:_BETTIK_PORT_API. Allocated at first build, kept for the environment's life, freed when the name goes. _BETTIK_HOST_<NAME>,_BETTIK_URL_<NAME>- The hostname and URL of each
urlsentry. _BETTIK_PROJECT- The compose project name: for
container_name, so two environments' containers never collide. _BETTIK_DATA- The environment's directory on the host, for bind mounts:
${_BETTIK_DATA}:/bundle. _BETTIK_NETWORK- This environment's own network. Its services and the workbench are on it, and no other environment is.
_BETTIK_GRANT_TOKEN,_BETTIK_GRANT_URL- Only for an environment with
grants: its token, and where to send it. See grants. - the secrets
- Each name in
secrets, with its minted value. - the
envmap DATABASE_URLand whatever elsebettik.jsonsays, expanded.
In the scripts, additionally:
_BETTIK_SEED_FROMblank, orsnapshotfor a saved environment: how this environment was installed.seedonly.OLD_*- When from a saved environment: every secret and
envvalue as the data was made with.seedonly, for rotating. GITHUB_TOKEN- The owner's token, if they set one in Settings. For cloning; never write it into a file.
Grants: what an environment may ask its box
An environment can ask its box to do a few things to itself, from its own code, on its own clock: back itself up every night, keep only the newest backups, tell your sessions something happened. It names them in bettik.json, each with a line saying what it is for:
"grants": {
"snapshot": { "description": "a backup of your notes every night" },
"prune": { "description": "keep only the last two backups" },
"gateway": { "description": "tell your sessions when someone shares a note", "topics": ["spaces"] }
}
The template page shows them under What it may do before anyone installs it, like an app's permissions. When the environment starts, the box writes one token for exactly these into .env, _BETTIK_GRANT_TOKEN, and where to send it, _BETTIK_GRANT_URL. Pass them into the app in compose.yaml like any other variable. Every call carries Authorization: Bearer $_BETTIK_GRANT_TOKEN:
POST /bundle/snapshot- Save this environment, as
save_environmentdoes: kept in the account that owns the box, under the environment's own name. It answers at once with202; the environment is paused for the few seconds the copy takes.snapshot. GET /bundle/snapshots- Its own saved environments, newest first, and how the last one it asked for went.
snapshot. POST /bundle/prune- Delete its own saved environments but the newest
{ "keep": 2 }(1 to 20, two if left out). Only ones its grant took: never a saved environment you saved yourself, never one still being stored.prune. POST /topics/<topic>- Post to a topic it was granted, or one under it, as the gateway takes from anyone. The message says it is from
bundle-<name>.gateway. GET /bundle- Which environment the token is, and what it was granted.
What the environment may do is what you agreed to: the grants its template showed when you installed it, or what bettik.json says when you (or Claude, for you) run build_environment. A start never adds one, so an environment cannot grant itself more by editing its own bettik.json; a grant it stops asking for is dropped at once. A clone has what its source had.
The token acts on that one environment and nothing else: no path or body names another, it cannot read topics, and it never reaches the box, the account or your other environments. A clone gets a token of its own, a saved environment carries none, and destroying the environment ends it. Scheduling is the environment's own: a process inside it runs on its own clock. An environment is refused, and told to rename, when its saved environment would land in a template someone else made.
compose.yaml, the parts Bettik relies on
Two things in the shipped file are load-bearing and worth keeping when you rewrite it:
- The app service mounts the environment's directory at
/bundle, listens on$PORT, and runs the loop that executes/bundle/startand runs it again when it exits. - Every service joins the default network,
${_BETTIK_NETWORK}(external), and carries an alias${_BETTIK_ENVIRONMENT}-<name>, which is how the workbench and the app reach them by name. It is the environment's own network: another environment is not on it, and a compose file that names another external network is refused.
A shell $ in the file must be written $$: Compose interpolates the file first. Tools your scripts need that the runtime image lacks — git on a slim image, pnpm, psql — belong in an image/Dockerfile beside compose.yaml (build: ./image), not installed at seed time into a container that will be replaced.
Memory, CPU and process limits are applied by Bettik to every container after the environment comes up, not read from the file: a mem_limit in compose.yaml is replaced by the box's own ceiling per container.
The lifecycle, as lived
Install empty. Edit compose.yaml until the setup is the one you need and build_environment. Put a project in projects/ and a line in start; write the step into seed. Save it. Next time, install that. Clone before an upgrade; edit the clone's compose.yaml; update_environment; keep it or throw it away.