# Hosting an app on a Lathe box

The apps engine runs your own container image on the machine that already runs your databases: the engines' URLs
arrive in its environment, the box serves it over https on its own name and on your domains, and a deploy moves
traffic only once the new container answers. This document is the whole contract, for an agent writing the code.
The same material for a person to read is on the docs site, at https://docs.lathe.live/tutorials.

Every step below is available three ways with the same rules and the same refusals: the portal's Apps tab, the REST
API (https://app.lathe.live/api/v1) and the MCP server (https://mcp.lathe.live). Each step names the MCP tool first and
the REST route second. Both take the same API key, made on the portal's Access page.

## 1. Turn the apps engine on

    configure_engines(instance_id, engines={"postgres": {"memory_mb": 2048}, "apps": {"memory_mb": 512}})
    PUT https://app.lathe.live/api/v1/instances/{id}/engines

At least 256 MB, out of the plan's memory budget; every app on the box takes its share of that one
figure. Name every engine you want running, not only `apps`: an engine left out of the call is stopped, its data
kept. This queues a job; poll get_job until it is done.

## 2. Build an image that keeps three rules

Any language, any base image, built for linux/amd64. On an Apple Silicon machine pass
`--platform linux/amd64` to `docker build`.

1. Listen on the port in `PORT`. The box sets it, so do not hard-code one. Loopback or all interfaces both work.
2. Answer the health path with a 2xx once the app is ready to serve. A new version takes traffic only after this
   answers, and one that never answers is left out with the running version still serving.
3. Keep state under `/data`. It is a directory of the box's disk mounted into the container, kept across deploys
   and restarts and left in place when the app is removed. Everything else in the container is gone at the next
   deploy, so write nothing you want to keep anywhere else.

    FROM node:22-slim
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --omit=dev
    COPY . .
    ENV NODE_ENV=production
    CMD ["node", "server.js"]        # server.js: app.listen(process.env.PORT)

## 3. Add the app

    set_app(instance_id, name="web", image="ghcr.io/you/app:main", port=8080,
            health_path="/healthz", public=true, memory_mb=512)
    PUT https://app.lathe.live/api/v1/instances/{id}/apps/{name}

| field | what it takes |
| --- | --- |
| name | Lowercase letters, digits and hyphens. Other apps on the box reach it as `<NAME>_URL` once they link it. |
| image | The tag to pull, e.g. `ghcr.io/you/app:main`. Point it at a tag your CI overwrites and a deploy needs nothing else changed. |
| port | What the app listens on. It owns this port and the two above it, which are the slots a new version comes up on, so leave three between two apps: 8080, 8083, 8086. A few ports belong to the box itself (its engines, the pooler, the SQL console); one of those is refused with a sentence saying what holds it. |
| health_path | The path from rule two. `/` by default, which is the wrong answer for anything that renders a page on demand: `/` is then the most expensive route on the site and usually touches the database, the box waits on it before moving traffic, and every deploy pays that render. Point it at an endpoint that touches nothing and answers in well under a second. |
| public | true: served by the box's front door on a name of its own, `<instance>-<name>.<the machine's zone>` (list_apps carries the URL), and on the domains below. false: loopback only, for the box's other apps. |
| default | true: the machine's own name goes to this app as well. One app per box; marking one unmarks the other. Unmarked everywhere, the first public app answers there. |
| memory_mb | This app's share of the engine's budget. |
| domains | Hostnames of yours that already point at the machine. The box gets a certificate for each. |
| links | Other apps on this box this one talks to. Each becomes `<NAME>_URL` in its environment. |
| singleton | true for an app that must never run twice (it holds a lock or its own files): restarted in place, so a deploy has a short gap. |
| read_only | true runs it on a read-only root filesystem, `/data` and `/tmp` still writable. Turn it on once the image tolerates it. |
| capabilities | The Linux capabilities the image needs, e.g. `CAP_NET_BIND_SERVICE`. None by default. |

A field you leave out keeps the value the app already has, and takes its default above only on an app being created,
so no call of yours moves a setting it did not name. To change one thing on an app that exists, say so:

    update_app(instance_id, name="web", memory_mb=640)                 # the port, the health path and public are untouched
    PATCH https://app.lathe.live/api/v1/instances/{id}/apps/{name}

The box runs the image you name, so name one you trust. A private image needs credentials, once per box:

    set_pull_registry(instance_id, registry="ghcr.io", pull_user="you", pull_token="…")
    PUT https://app.lathe.live/api/v1/instances/{id}/registry

For GitHub's registry that is a GitHub username and a personal access token with the `read:packages` scope.
GitHub packages are private by default, and a workflow's own GITHUB_TOKEN lives only while the workflow runs,
so the box needs a token of its own to pull with. This holds for an imported or connected repository
too (section 7): GitHub's registry takes a personal access token, not the Lathe app's own credential, so a private
image always needs this one.
## 4. Give it its environment

    set_app_env(instance_id, name, env={"SESSION_SECRET": "…"})        # replaces the whole environment
    update_app_env(instance_id, name, set={"SESSION_SECRET": "…"}, unset=["OLD"])   # changes some, keeps the rest
    PUT https://app.lathe.live/api/v1/instances/{id}/apps/{name}/env   |   PATCH the same path

Use update_app_env unless you hold the whole environment: nothing reads the values back. The box injects `PORT` and,
for **every engine on the machine**, a connection string and its parts - so a library that takes a URL and one that
reads `PGHOST` both work with nothing to configure. They are written before your variables and yours win; override a
URL and its parts together, or a library that reads the parts will still find the box. Turn an engine on later and
its variables appear at that deploy; turn it off and they stop being set.

- Postgres 17: DATABASE_URL, DATABASE_POOLED_URL, PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, PGSSLMODE, PGSSLROOTCERT, DATABASE_READONLY_URL, DATABASE_POOLED_READONLY_URL
- Redis 8: REDIS_URL, REDIS_HOST, REDIS_PORT, REDIS_USER, REDIS_PASSWORD, REDIS_TLS
- CouchDB 3: COUCHDB_URL, COUCHDB_HOST, COUCHDB_PORT, COUCHDB_USER, COUCHDB_PASSWORD
- NATS 2: NATS_URL, NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD
- `<NAME>_URL` for each app named in `links`, on loopback, unchanged while that app's containers come and go.
- Auth, for an app whose `links` name it (it is not given to an app that does not ask): AUTH_URL, AUTH_PUBLIC_URL, AUTH_ANON_KEY, AUTH_SERVICE_KEY, AUTH_JWKS_URL

`list_apps` reports these per app as `injected`, so an agent can read what an app is given rather than guess.

`DATABASE_URL` is the owner role: it can write, and it can create databases. `DATABASE_READONLY_URL` is the same
data through a role that cannot change any of it - it holds `pg_read_all_data` and nothing else, and every
transaction it opens is read-only, so the server refuses a write rather than trusting the code not to try one. Where
you know a query is a read, send it there: a bug or an injection on that path then cannot write, whatever it asks
for. Tables you create later need no new grant. Both come with a pooled sibling on 6432.

Every one of them asks for `sslmode=verify-full` and names an `sslrootcert`: the box mounts its own CA bundle into
your container at `/etc/lathe/ca.crt`, read-only, and the URLs point at that file. libpq does not use the system
trust store, so `verify-full` on its own would send psql, psycopg, Ruby `pg`, `pdo_pgsql` and pgx looking for
`~/.postgresql/root.crt` and refusing to connect; naming a real path works on every client. If you build the URL
yourself rather than using ours, carry the same two parameters.

At most 96 variables of yours, each value under 4 kB. Values are kept with the machine's secrets and are
never returned; list_apps reports the names only. Saving restarts the app with the new environment.

## 5. Put it on a domain of your own

Point an A record at the machine's IPv4 (get_instance carries it), then add the hostname to `domains` on the app.
The box gets a certificate for it within a minute of the record resolving. The app keeps answering on its own name
as well, which is where a public app answers before any domain is added.

## 6. Ship a new version

    deploy_app(instance_id, name)
    POST https://app.lathe.live/api/v1/instances/{id}/apps/{name}/actions/deploy

Pulls the image again, tag unchanged or not: what to call after pushing a new build to the tag the app carries. The
box starts the new container in the app's spare slot, waits for the health path, moves the front door to it and only
then stops the old one, so requests keep being served. A `singleton` app is restarted in place instead. An image that
never answers its health path fails the job and leaves the old container serving, so a broken build cannot take the
app down. Read what it printed with app_logs (GET the same path plus /logs); roll back by setting `image` to the
older tag.

    restart_app(instance_id, name)
    POST https://app.lathe.live/api/v1/instances/{id}/apps/{name}/actions/restart

The other tool, for when the image is not what wants changing: the container it is already running is stopped and
started again, in its own slot, in a few seconds. Nothing is pulled and no other app or engine on the box is
touched. For a process that has wedged, a connection pool that will not recover, or state only a fresh start
clears - not for a new build, which is the deploy above.

## 7. Deploy from GitHub Actions

There are three ways to get a build onto the box, and the first two need nothing of ours in your repository.

**Import the repository**, one call, the way to put a person's code on a box:

    import_repo(instance_id, repo="you/app")
    POST https://app.lathe.live/api/v1/instances/{id}/apps/import    {"repo": "you/app"}

Lathe reads the repository (`inspect_repo`, `GET /github/repos/{owner}/{name}?instance_id=…` shows what it
finds), takes the workflow that already builds and pushes an image and the reference it pushes, or opens a pull
request adding `.github/workflows/lathe.yml` (and a Dockerfile, when the repository has none and is Node, Python,
Go or a static site), creates the app with the port and health path the Dockerfile says, and deploys the newest
green build. With no build yet the app waits (`pending` in list_apps) and the first green run of the workflow on
the branch puts it on the box; every green run after that deploys it, pinned by digest, and `roll_back_app` goes
back to any of them. Every field of set_app can be given to override what the repository suggests; `env` is
saved before the first deploy. A private image needs the registry credentials of section 3 - ghcr.io authenticates a
personal access token with `read:packages`, and refuses the Lathe app's own credential. The answer's `image_state`
says whether the registry will actually let the box pull the image, asked rather than guessed: `denied` is the only
case that needs a token (`missing` is a tag the first build has not pushed yet), and it carries the URL of the page
that makes one. The repository must be one the person installed the Lathe app on, from
the Integrations page of the portal (`list_repos` says which).

**Connect the repository** (Integrations page, then Connect GitHub; then the app's card, then Connect this app to a
repository). You pick the workflow that already builds and pushes your image and the branch that counts, and from
then on a green run of it deploys the app. No secret of ours goes into your repository and no step of ours goes into
your workflow, so nothing can fall out of sync with it. The app's card then shows every run of that workflow, names
the commit that is serving, and keeps the builds it deployed so you can put one back: a deploy pins the image by
digest, so going back means the same bytes even though the tag has moved. The workflow below still describes what to
build and push; with the repository connected, leave its last step out.

**Or call the deploy endpoint yourself**, which is what the rest of this section describes and what a workflow that
is not on GitHub, or a repository you would rather not connect, still does.

Either way the box pulls the image with the registry credentials you saved for it, so a private image still needs
those; connecting a repository changes who asks for the deploy, not who the machine logs in to the registry as.

The workflow below is the one Lathe deploys itself with (the service answering this call is an app on a box):
build on every push to `main`, push `:main`
and `:<commit>` to the registry, then ask the box to deploy. Save it as `.github/workflows/deploy.yml`.

Two repository secrets (Settings, then Secrets and variables, then Actions):

| secret | value |
| --- | --- |
| LATHE_DEPLOY_KEY | An API key from the portal's Access page, `lathe_…`. It acts as the customer. |
| LATHE_DEPLOY_INSTANCE | The instance id. |

Without them the deploy step is skipped, which is what a pull request from a fork gets.

```yaml
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: |
            ghcr.io/you/app:main
            ghcr.io/you/app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Deploy on Lathe
        env:
          KEY: ${{ secrets.LATHE_DEPLOY_KEY }}
          INSTANCE: ${{ secrets.LATHE_DEPLOY_INSTANCE }}
          BASE: https://app.lathe.live/api/v1
          APP: web
        if: env.KEY != '' && env.INSTANCE != ''
        run: |
          for try in 1 2 3 4 5; do
            code=$(curl -sS -o /tmp/deploy.json -w '%{http_code}' -X POST \
              -H "Authorization: Bearer $KEY" \
              "$BASE/instances/$INSTANCE/apps/$APP/actions/deploy") || code=000
            cat /tmp/deploy.json; echo
            case "$code" in
              2*)  echo "deploy queued"; exit 0 ;;
              409) echo "the instance is busy; trying again in 30s ($try/5)"; sleep 30 ;;
              *)   echo "the deploy was refused with $code"; exit 1 ;;
            esac
          done
          echo "still busy after five tries"; exit 1
```
The box runs one job at a time, so a deploy that arrives while another job is running is refused with 409; the loop
above is why. The endpoint answers 202 with a job id, which get_job follows, as does a webhook if one is set.

## What an app gets, and the rules it runs under

| item | detail |
| --- | --- |
| Network | The engines are reached on the machine's own name over TLS, like any other client. Apps on the same box reach each other on loopback through `<NAME>_URL`. |
| Disk | `/data` is kept and stays after a removal. `/tmp` is private and emptied at every start. |
| Memory | The app's figure is its ceiling. Automatic reallocation moves memory between the box's engines and apps from what each holds, restarting nothing. |
| Deploys | Two slots per app, traffic moved after the health path answers. A box without the memory for two containers at once restarts in place and says so in the log. |
| Hardening | No privileges beyond the capabilities named, no privilege gain, a ceiling on processes. |
| Public apps | Served on 443, each on a name of its own (`<instance>-<name>.<the machine's zone>`) with a certificate of its own, as custom domains are. The machine's own name, with the machine's certificate, goes to the one app marked default. |
| Jobs | One job per instance at a time; a busy instance refuses with 409 and a sentence. Slow steps return a job id for get_job. |

## Sign-in for the app: the auth engine

Auth, run by the box beside the app: email links and codes, OAuth (GitHub, Google), sessions and refresh tokens,
with the users in the `auth` schema of the instance's own Postgres, readable by your role. It is built on the
open-source Supabase Auth server, so supabase-js and the Supabase auth clients work against it unchanged. One call
turns it on, nothing to fill in:

    configure_engines(instance_id, engines={"postgres": {}, "apps": {}, "auth": {}})
    PUT https://app.lathe.live/api/v1/instances/{id}/engines

It needs Postgres and the apps engine on the same box (it runs behind their front door) and takes 128 MB by default.
When the job is done, `get_connection_urls` carries an `auth` entry:

| field | what it is |
| --- | --- |
| `url` | the server's public address, `/auth/v1` under a name of its own rather than your app's (Supabase's layout, so their clients work) |
| `anon_key` | the key a browser or a mobile app presents; safe to ship in a page |
| `service_key` | opens the admin API (`/admin/users`, invitations, deletions); a server secret, like the database password |
| `jwks_url` | the public keys, to verify a user's access token without asking the server |
| `callback_url` | the redirect URI to register with GitHub or Google, ready to paste. It follows `auth_domain`, so register both names if you plan to move to one of yours |
| `loopback_url` | `http://127.0.0.1:9999`: the address an app on the box uses, admin routes included |

**An app on the box** declares `links: ["auth"]` in `set_app` and finds `AUTH_URL` (the loopback address),
`AUTH_PUBLIC_URL`, `AUTH_ANON_KEY`, `AUTH_SERVICE_KEY` and `AUTH_JWKS_URL` in its environment. Nothing else to configure.

**Watch the one difference between those two URLs.** `AUTH_PUBLIC_URL` already ends in `/auth/v1`; `AUTH_URL`, the
loopback address, does not. They sit next to each other and read as interchangeable, and they are not: append the
path yourself on `AUTH_URL` (`${AUTH_URL}/auth/v1/token?grant_type=password`) or the server answers 404 and the 404
looks like a misconfigured engine rather than a missing prefix. Reported by the first adopter, who lost a re-read to
it (2026-09-13).

**Server-side only**, if you would rather nothing auth-related reached the browser: call the engine over `AUTH_URL`
from your own routes, keep the session in your own `httpOnly` cookie, and ship no key and no client in the page.
`/auth/v1/token?grant_type=password`, `/otp`, `/verify` and `/user` are the four the REST API needs, with
`apikey: ${AUTH_ANON_KEY}` on each; the refresh token stays in your cookie and your route swaps it. This is the
natural shape for anything server-rendered, and the anon key never has to be baked into a build. The browser
section below is the alternative rather than the default.

**A browser** uses supabase-js unchanged: `createClient(url_without_the_path, anon_key)` - the client appends
`/auth/v1` itself - then `signInWithOtp`, `signInWithOAuth`, `getSession` as documented by Supabase. Or
`@supabase/auth-js` with the `url` above. Tokens are ES256 JWTs; verify them against `jwks_url` in your own code.

**A server anywhere** calls the same URL with `apikey: <service_key>` and `Authorization: Bearer <service_key>` for the
admin routes, once the setting `admin_api` is `public` (it is `loopback` until then: refused with 404 from outside the box).

**Settings** are grouped, and each group saves on its own: `update_engine_settings(instance_id, "auth", {...})`
(`PATCH …/engines/auth/settings`) changes the keys you give and leaves every other one alone, which is what you
want. `configure_engine_settings` (`PUT`) replaces the lot, so a key you leave out goes back to its default. An
empty value puts a key back to its default, which is how you clear a provider or a custom domain. Every key, its
bounds, its default and its group are under `engines.auth.settings` in `list_plans`.

| group | what is in it |
| --- | --- |
| `general` | `site_url`, `redirect_urls`, `signups`, `email_signin`, `magic_link`, `authorized_addresses` to let only some addresses in, `otp_expiry_s`, `otp_length`, `admin_api`, `auth_domain` |
| `providers` | `<name>_client_id` and `<name>_secret` for github, google, apple, azure, bitbucket, discord, facebook, figma, fly, gitlab, kakao, keycloak, linkedin_oidc, notion, slack_oidc, spotify, twitch, workos, x and zoom, plus `<name>_url` for azure, gitlab, keycloak and workos. Both halves or neither: one alone is refused, since the server would ignore it |
| `mail` | `smtp_host/port/user/pass/from`, `emails_per_hour`, `mail_min_interval_s` (what a Resend button runs into), `mail_template` and `mail_subject` for the sign-in mail, a `_template` and a `_subject` each for `invite`, `recovery`, `email_change` and `reauthentication`, `secure_email_change`, and the `notify_*` mails for a changed password or address and a linked or unlinked sign-in method |
| `security` | `password_min_length`, `password_characters`, `password_breach_check` against Have I Been Pwned, `captcha` (hcaptcha or turnstile) and `captcha_secret`, `refresh_token_rotation` and `refresh_token_reuse_s`, `reauth_for_password_change`, `current_password_to_change`, `manual_linking` for `linkIdentity`, and the per-address limits `verify_per_5min`, `otp_requests_per_5min`, `token_refresh_per_5min`, `mfa_attempts_per_min` |
| `sessions` | `jwt_expiry_s`, `session_timebox_h`, `session_inactivity_h`, `single_session` |
| `mfa` | `totp` (on by default), `passkeys`, `webauthn`, `recovery_codes`, `max_factors`. Passkeys and security keys are bound to your app's own host, so `site_url` must be set first |

An on/off setting takes `on` or `off`. A secret reads back as a mask; the mask sent back keeps it, and it never
appears in your activity feed. Anything this schema does not carry - phone and SMS, SAML, the auth hooks - goes on
the apps engine's environment for the app named `auth`: `update_app_env(instance_id, "auth", set={...})`, layered
over what the settings render.

`rotate_password(engine="auth")` turns the engine's database password and keeps the keys, which every session hangs
on. `reset_engine("auth")` drops the schema and starts empty. `app_logs(name="auth")` reads the server's log.