lathe

Tutorials

Step by step, with what runs on your box. Two so far: hosting your own app beside its databases, and deploying it from GitHub Actions on every push - the way Lathe deploys itself; then sign-in for the app with the Auth engine. All of it is also one instruction to a coding agent holding your API key.

On this page: Have your coding agent do it · Host an app on your box · Deploy from GitHub Actions · What an app gets, and the rules it runs under · Sign-in for the app

Have your coding agent do it

Everything below is on the MCP server and the REST API with the same API key. An agent working on your code can switch the engine on, write the Dockerfile, add the app and commit the workflow without you leaving the editor.

Connect it once, with a key from the Access page. Claude Code:

claude mcp add --transport http lathe https://mcp.lathe.live --header "Authorization: Bearer lathe_…"

Then ask it to put the app on the box. It reads this page's contract itself with the app_guide tool: the three rules an image keeps, every field an app takes, what the box injects into the environment, and the workflow from the second tutorial, all addressed to this deployment rather than remembered from somewhere else. Anything that speaks HTTP reads the same document:

curl -H "Authorization: Bearer lathe_…" https://app.lathe.live/api/v1/guides/apps

A key acts as you on every instance in the account, so hand it to an agent you would hand the portal to. The rest of this page is the same ground for a person doing it by hand.

Host an app on your box

A container image of yours, running on the same machine as its Postgres, Redis, CouchDB or NATS, served over https on the machine's name and on your own domains.

1. Switch the Apps engine on

On a new instance, tick Apps on the create form. On one that already runs, open the instance's Settings tab, tick Apps under Engines, give it a memory budget (at least 256 MB; every app you add later takes its share of it) and apply. The box installs what the engine needs and the instance page gets an Apps tab.

2. Build an image that follows three rules

Any language, any base image. The contract is small:

  • Listen on PORT. The box sets it; do not hard-code a port. Listening on all interfaces or on loopback both work.
  • Answer the health path (/ by default, or one you name such as /healthz) with a 2xx once the app is ready to serve. A new version takes traffic only after this answers.
  • 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.
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)

The image is pulled for linux/amd64. If you build on an Apple Silicon machine, pass --platform linux/amd64 to docker build.

3. Push it to a registry

docker build --platform linux/amd64 -t ghcr.io/you/app:1.0 .
docker push ghcr.io/you/app:1.0

A public image needs nothing more. For a private one, save registry credentials at the bottom of the Apps tab: for GitHub's registry that is your GitHub username and a personal access token with the read:packages scope. One set of credentials serves every app on the box; the registry is ghcr.io unless you name another.

4. Add the app

On the Apps tab, under Add an app:

FieldWhat to put
NameLowercase letters, digits and hyphens, e.g. web. Other apps on the box reach it as WEB_URL once they link it.
ImageThe tag you pushed, e.g. ghcr.io/you/app:1.0.
PortWhat the app listens on, e.g. 8080. Each app owns its port and the two above it (its deploy slots), so leave three between two apps: 8080, 8083, 8086. A few ports belong to the box itself (its engines, the pooler, the SQL console); the form refuses one of those and says what holds it.
Health pathThe path from rule two.
Memory (MB)This app's share of the engine's budget.
PublicTicked, the app answers at https://<instance>-<name>.<the machine's zone>/, a name of its own, and on its custom domains. Unticked, it listens on loopback for the box's other apps only.
Machine's nameTicked, the app answers at https://<the machine's name>/ as well. One app per machine; the first public one, unless another is ticked.
LinksOther apps on this box it talks to, space-separated. Each becomes NAME_URL in its environment.
Single copyFor an app that holds a lock or its own files: restarted in place, never run twice, so a deploy has a short gap.

Press Add. The change is a job of about a minute: the box pulls the image, starts the container, waits for the health path and, for a public app, serves it with the machine's certificate. The Apps tab shows it with Deploy, Restart, Logs and Remove.

5. Give it its environment

Each app has an Environment card: a row per variable, its name beside its value. The machine injects the URL of every engine on it (DATABASE_URL, DATABASE_POOLED_URL, REDIS_URL, COUCHDB_URL, NATS_URL, each with its password) and PORT; you add the rest, and yours win over the injected ones. Saving restarts the app with the new environment. The page is served without your values in it and a row left blank keeps the value it has, but Show values fetches them for you, so treat anyone who can sign in to your account as able to read them; the API and MCP report the names only. Paste a .env file on the same card replaces the whole environment at once.

SESSION_SECRET=change-me
SMTP_URL=smtp://user:pass@smtp.example.com:587

6. Put it on your own domain

Point an A record at the machine's IP (on the instance's Overview), then add the hostname under Custom domains on the app and apply. The box issues a certificate for it within a minute of the record resolving. The app keeps answering on the machine's own name as well.

7. Ship the next version

Push a new tag and change Image, or push to the same tag and press Deploy: the box pulls again, starts the new container beside the running one, waits for the health path, moves traffic and only then stops the old one. An image that never answers leaves the running one in place and says so in the log. Logs shows the last lines from the machine. The next tutorial makes this a push to GitHub.

Restart is the other button, for when the image is not what wants changing: the container the app is already running is stopped and started again, in its own slot, in a few seconds. Nothing is pulled and no other app on the box is touched - for a process that has wedged, a pool that will not recover, or state only a fresh start clears.

The same from code or an agent

Everything the tab does is on the REST API and the MCP server with the same API key: PUT /instances/{id}/apps/{name} adds or changes an app, PUT …/apps/{name}/env replaces its environment, PATCH …/apps/{name}/env sets or unsets some variables and keeps the rest, PUT …/registry saves the pull credentials, GET …/apps/{name}/logs reads the log. The MCP tools are list_apps, set_app, set_app_env, update_app_env, deploy_app, restart_app, app_logs, remove_app and set_pull_registry.

curl -X PUT -H "Authorization: Bearer lathe_…" -H "Content-Type: application/json" \
  -d '{"image": "ghcr.io/you/app:1.0", "port": 8080, "health_path": "/healthz", "public": true, "memory_mb": 512}' \
  https://app.lathe.live/api/v1/instances/ID/apps/web

Deploy from GitHub Actions

Every push to main builds the image, pushes it to GitHub's registry and asks Lathe to deploy it. This is the workflow Lathe runs on itself: the service you are reading is an app on a Lathe box, deployed this way.

There is a shorter way now: import the repository. On the Apps tab, Import a repository reads it, finds the workflow that builds its image or opens a pull request adding one (with a Dockerfile when there is none), creates the app with the port and health path the Dockerfile says, and deploys the newest green build; until there is one the app waits, and the first green run puts it on the box. A private image still needs the registry credentials of step 1: GitHub's registry takes a personal access token with read:packages and will not accept the Lathe app's own. The same in one call for an agent: import_repo, or POST /instances/{id}/apps/import.

Or connect the repository to an app you already added. Connect GitHub on your Integrations page, then pick this repository and its build workflow from the app's card. A green run of that workflow deploys the app, with no key in your repository and no step of ours in your workflow, and the card then names the commit that is running and keeps the builds it deployed so you can put one back. Step 2 below is then unnecessary and the workflow in step 3 keeps only its build and push; the registry credentials in step 1 are still what the box pulls a private image with.

1. Run the app from a moving tag

Set the app's Image to a tag that CI overwrites, such as ghcr.io/you/app:main. A deploy pulls the tag again whether or not its name changed, so nothing on the box has to change per build. GitHub packages are private by default: save your GitHub username and a personal access token with read:packages as the registry credentials on the Apps tab (the workflow's own GITHUB_TOKEN can push the image, but it lives only while the workflow runs, so the box needs a token of its own to pull).

2. Give the repository a key and the instance id

Create an API key on the Access page; it acts as you. Copy the instance id from the instance page. Add both as repository secrets (Settings → Secrets and variables → Actions):

SecretValue
LATHE_DEPLOY_KEYThe API key, lathe_….
LATHE_DEPLOY_INSTANCEThe instance id.

3. The workflow

Save this as .github/workflows/deploy.yml, with your image name and the app's name on the box. It builds on every push to main, pushes :main and :<commit> (so any build can be put back by changing Image), then calls the deploy endpoint. A box busy with another job answers 409; the step tries five times, thirty seconds apart. Without the two secrets the deploy step is skipped, which is what a fork gets.

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; press Deploy on the Apps tab"; exit 1

4. What happens on the box

The endpoint answers 202 with a job id. The box pulls the tag, starts the new container in the app's spare slot, waits for the health path, moves traffic to it and stops the old container: requests keep being served throughout. An image that never answers its health path leaves the old one serving and fails the job, so a broken build does not take the app down. Follow the job with GET https://app.lathe.live/api/v1/jobs/{id}, on the instance's Activity, or with a webhook. An app marked single copy is restarted in place instead, with a gap of a few seconds.

Rolling back

Set Image to the :<commit> tag of the build you want and apply. It is the same deploy, in the other direction.

From an agent instead

An agent connected to the MCP server does the last step with deploy_app, which takes the instance id and the app's name.

What an app gets, and the rules it runs under

ItemDetail
EnvironmentPORT; the URL of every engine on the box (DATABASE_URL, DATABASE_POOLED_URL, REDIS_URL, COUCHDB_URL, NATS_URL); NAME_URL for each app it links; then yours. At most 96 variables of yours, each value under 4 kB.
NetworkThe engines are reached by the machine's own name, over TLS, like any other client. Apps on the same box reach each other on loopback through their NAME_URL, which does not change while their containers do.
Disk/data is kept; it belongs to the app for as long as the app exists on the box and stays after a Remove. /tmp is private and emptied at each start.
MemoryThe app's figure on the tab is its ceiling. Automatic reallocation moves memory between the box's engines and apps from what each one holds; the Settings tab has the switch.
DeploysTwo slots per app: the new container comes up beside the old one and takes over once it answers its health path. Single copy: restarted in place. A box without the memory for two containers at once restarts in place and says so in the log.
HardeningNo privileges beyond the capabilities you name (e.g. CAP_NET_BIND_SERVICE), no privilege gain, a ceiling on processes. Read-only root makes the whole filesystem but /data and /tmp read-only; turn it on once you know the image tolerates it.
Public appsServed 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 for it.

Sign-in for the app

The Auth engine runs sign-in on the same machine: email links and codes, OAuth with GitHub, Google and the rest, multi-factor, sessions, 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 works unchanged. No price per user.

Switch it on from the instance page's Engines settings (it needs Postgres and Apps on the same box; nothing to fill in), or with configure_engines. When the job is done the engine's tab shows its URL, the anon key for browsers, the service key for your server and the callback address to register with a provider. An app on the box that links auth finds AUTH_URL, AUTH_ANON_KEY, AUTH_SERVICE_KEY and AUTH_JWKS_URL in its environment.

In the browser: createClient(url, anon_key), then signInWithOtp, signInWithOAuth and getSession as documented by Supabase. Tokens are ES256 JWTs; verify them in your own code against the keys URL. Every setting, grouped into tabs on the engine's card: site URL and redirects, providers, mail, security, sessions and MFA. The agent guide above carries the same section, addressed to this deployment.

Are you sure?