---
name: zeit
description: >-
  Deploy a small Go or TypeScript service to clausewitz.xyz with the zeit CLI.
  Use when asked to deploy an API, put an endpoint online, register a subdomain,
  read a deployed service's logs, or manage its environment variables.
---

# zeit

zeit is a self-hosted deploy platform running on one VPS. It does what Vercel
does for small API endpoints — a subdomain, TLS, environment variables, logs,
rollback — for services that should live on hardware their author controls.

It deploys from any directory on the machine, not only from the repo it is part
of. A project needs one file, `zeit.toml`, in its root.

## Before anything else

```bash
zeit skill      # this document, always current for the installed binary
zeit apps       # what is deployed right now
```

If `zeit` is not installed:

```bash
curl -fsSL https://zeit.clausewitz.xyz/install.sh | sh
```

## Two hostnames

Apps are published under clausewitz.xyz — an app called `fuel` answers
at `fuel.clausewitz.xyz`. The platform itself is somewhere else:
`zeit.clausewitz.xyz` serves the dashboard, the deploy API, this
document and the installer. The apex, `clausewitz.xyz`, is a public
page and has no API on it at all.

The CLI already knows this. Only override it with `$ZEIT_URL` when
pointing at a hub somewhere else entirely.

## Authentication

One password protects the whole platform: the CLI and the dashboard share it.
`zeit login` stores it in `~/.config/zeit/config.toml`. In a
non-interactive context read it from the environment instead:

```bash
export ZEIT_URL=https://zeit.clausewitz.xyz
export ZEIT_TOKEN=…    # the admin password
```

Never write the token into a file in a user's repository, and never echo it.

## The manifest

```toml
name      = "fuel"     # the app's identity: unit name, directory under /opt
subdomain = "fuel"     # -> fuel.clausewitz.xyz; defaults to name
runtime   = "go"       # "go" or "bun"
build     = "./cmd/server"   # go only: the main package to cross-compile
# dir     = "api"            # bun only: the directory of route modules
memory    = "512M"

# domain   = "api.example.com"   # an extra hostname outside clausewitz.xyz
# database = "sqlite"            # /opt/<name>/<name>.db, replicated to R2
```

There is no `port` key. The hub allocates the port and passes it to the
app as `$PORT`; an app that hardcodes a port will not be reachable.

Decoding is strict — an unknown or misspelled key is an error, not a setting that
silently does nothing. Names and subdomains must match `^[a-z][a-z0-9-]{0,30}$`
and not end in a hyphen.

## The two runtimes

### go

Cross-compiled locally to linux/amd64 and uploaded as a single binary; the server
needs no Go toolchain. The service must listen on `$PORT` and should
answer `GET /healthz` — the deploy waits for that endpoint and rolls
back if it never answers.

### bun

The route directory is uploaded and served by a file router. Each module maps to
a path by its filename and exports one function per method:

```
api/query.ts    -> POST /api/query
api/advisor.ts  -> POST /api/advisor
api/index.ts    -> /api
```

```typescript
export async function POST(request: Request): Promise<Response> {
  return Response.json({ ok: true });
}
```

Handlers take a Web `Request` and return a Web `Response`, the
same shape Vercel and Cloudflare use, so route files usually move across
unchanged. Only static path segments are supported — there are no `[id]`
parameters; read those from the query string or the body.

`node_modules` is never uploaded. If the project has a
`package.json`, dependencies are installed on the server.

## Environment variables

```bash
zeit env set fuel DATABASE_URL=…       # one app
zeit env set --shared ANTHROPIC_API_KEY=…   # every app
zeit env list fuel                     # names only, never values
```

Shared variables are loaded first and an app-level variable of the same name wins.
`ANTHROPIC_API_KEY` belongs in the shared set so no single app owns it —
an app calling the Anthropic API just reads it from its environment.

Values are write-only. Nothing in the API or the dashboard will read one back, so
if a secret is lost it has to be re-set rather than recovered.

## Commands

### `zeit login`

Store the hub URL and admin password

Prompts for the admin password and writes it to ~/.config/zeit/config.toml (0600).
There is one credential for the whole platform: the same password opens the
dashboard in a browser. $ZEIT_URL and $ZEIT_TOKEN override the file, which is how
an agent or a CI job authenticates without logging in.

- `-url <url>` — hub URL (default https://zeit.clausewitz.xyz)

```bash
zeit login --url https://zeit.clausewitz.xyz
```

### `zeit init`

Write a zeit.toml for the current directory

Looks at the directory and guesses: a go.mod means the go runtime, a directory of
.ts files means bun. The result is a starting point to edit, not an answer.

```bash
cd ~/projects/fuel && zeit init
```

### `zeit validate`

Check zeit.toml without deploying

Runs the same checks `zeit deploy` runs first: the schema, the paths the manifest
names, and whether the subdomain is free on the hub. Changes nothing.

### `zeit deploy`

Build the current project and put it live

Validates, builds, uploads, releases, and waits for the app to answer before
reporting success. A release that fails its health check is rolled back to the
previous one and the journal lines that explain it are printed.

The go runtime is cross-compiled locally, so the server needs no Go toolchain.
The bun runtime uploads source, honouring .gitignore and always excluding
node_modules; dependencies are installed on the server.

- `-app <name>` — override the name in zeit.toml

```bash
zeit deploy
```

### `zeit apps`

List everything deployed on the box

Includes services deployed outside zeit by the hustles Makefile. Those are shown
and their logs are readable, but they cannot be deployed or removed from here.

### `zeit status <app>`

Show one app: URL, port, runtime, current release, recent deploys

### `zeit logs <app>`

Print an app's journal

- `-n <count>` — how many lines (default 100)
- `-f` — follow, polling for new lines

```bash
zeit logs fuel -n 200
```

### `zeit env set|list|rm <app> [KEY=VALUE ...]`

Manage an app's environment variables

Values are write-only: `env list` reports names and when they changed, never
contents. --shared reads and writes the set every app loads, which is where a key
like ANTHROPIC_API_KEY belongs so no single app owns it.

A change takes effect on the app's next restart, which `env set` triggers.

- `-shared` — act on the variables shared by every app

```bash
zeit env set fuel TURSO_AUTH_TOKEN=…
zeit env set --shared ANTHROPIC_API_KEY=…
```

### `zeit rollback <app>`

Put the previous release back

- `-to <release>` — a specific release from `zeit status`

### `zeit restart <app>`

Restart an app without deploying

### `zeit destroy <app>`

Unpublish an app

Stops the service and removes its unit and its Caddy site. The app's directory
under /opt is deliberately left behind, database and all — a destroy that quietly
deleted data would be a bad thing to have to undo.

### `zeit skill`

Print this manual

The same text served at https://zeit.clausewitz.xyz/skill.md.

## Deploying something new, end to end

```bash
cd ~/projects/thing
zeit init                  # writes zeit.toml, edit it
zeit validate              # schema + is the subdomain free
zeit env set thing KEY=…   # anything the service needs
zeit deploy
```

`zeit deploy` prints the URL when the app answers. If it does not, the
previous release keeps serving and the failing journal lines are printed — read
those before changing anything.

## What zeit will not do

- Take over a service deployed by the hustles Makefile. Those appear in
  `zeit apps` and their logs are readable, but `deploy`,
  `restart` and `destroy` refuse them.
- Give an app a reserved subdomain, or one another app already holds. Check with
  `zeit validate` before building anything.
- Delete an app's data. `zeit destroy` unpublishes the service and
  leaves `/opt/<name>` alone, database included.
