Self-hosting

Run TAI on a machine that isn't your laptop. The supported shape is one container and one volume.

bash
docker run -d --name tai \
  -p 127.0.0.1:3000:3000 \
  -v tai-data:/data \
  --add-host host.docker.internal:host-gateway \
  -e TAI_MODEL=llama3.2 \
  -e TAI_BASE_URL=http://host.docker.internal:11434/v1 \
  ghcr.io/quintonmiller/tai:latest

docker logs -f tai            # first boot prints the generated API token

Images are multi-arch, so the same tag runs on a Raspberry Pi, a Graviton instance, or an x86 server. :latest is the released tag and :edge tracks the tip of main.

The dashboard is now on http://127.0.0.1:3000 on the host. Read Exposing it before you change that.

With compose, which wires the volume, port publish, and healthcheck for you:

bash
git clone https://github.com/quintonmiller/tailored-ai
cd tailored-ai/docker/tai
cp .env.example .env          # set TAI_MODEL and TAI_BASE_URL
docker compose up -d
docker compose logs -f

That builds from source. Set TAI_IMAGE=ghcr.io/quintonmiller/tai:latest in the same .env to pull the published image instead.

One container, one volume

TAI keeps state in SQLite, and SQLite takes a single writer. There is no second replica to run and no load balancer to put in front. Two containers sharing one volume will corrupt the database.

Everything instance-scoped lives under one directory named by TAI_HOME: config.yaml, .env, agent.db, data/, and the plugin home that tai plugin install writes into. That directory is the volume. Back it up and you have backed up the instance.

Scale by giving the box more (a faster model endpoint, more RAM), not by adding boxes.

First boot

The entrypoint writes config.yaml if TAI_HOME has none, then starts the server. On every later boot the file already exists and is left alone. That is what makes restarts idempotent, and why edits you make to config.yaml in the volume stick.

It also means changing an environment variable after first boot does not move a setting the file already holds. Secrets are the exception: config references them as ${VAR}, so they resolve on every boot and you can rotate them.

Only TAI_MODEL is required. There is no default. A guessed model gives you a container that starts, passes its healthcheck, and then fails on the first message with a provider-side 404, which reads like a TAI bug.

VariableDefault
TAI_MODELnoneRequired. Model name your provider serves.
TAI_BASE_URLhttp://localhost:11434/v1OpenAI-wire endpoint.
TAI_PROVIDERopenai_compatibleAny registered provider id.
TAI_API_KEYnoneWritten to .env, referenced from config.
TAI_SERVER_HOST0.0.0.0 in the imageBind inside the container.
TAI_SERVER_PORT3000
TAI_AUTH_TOKENgeneratedBearer token for /api/*.
TAI_HOME/data in the imageThe volume.

To reach a model server running on the host, add --add-host host.docker.internal:host-gateway and point TAI_BASE_URL at http://host.docker.internal:11434/v1. Docker Desktop resolves that name already. On Linux it does not, and without the flag your first message fails on DNS rather than at startup.

Bring your own config

Mount a config you already have and generation is skipped:

bash
docker run -v /srv/tai:/data ... tai:local
# with /srv/tai/config.yaml already in place

Setup without a terminal

The interactive wizard needs a TTY, so it cannot finish over SSH-with-no-tty, in cloud-init, or during a container build. Use the headless path instead:

bash
tai init --non-interactive --model llama3.2 --base-url http://localhost:11434/v1
tai init --help      # full option list

Every flag also reads an environment variable (TAI_MODEL, TAI_BASE_URL, TAI_SERVER_HOST, and the rest), so a container can be configured entirely through its environment. Bind beyond loopback without supplying a token and setup generates one, stores it in .env, and prints it once.

Exposing it

Read this before you publish the port anywhere but loopback. The API serves chat history, memory, tasks, and tool output. There is no per-user model: whoever reaches it is the owner.

The image binds 0.0.0.0 inside the container because a container's loopback is reachable only from inside that container. Publishing a port to a 127.0.0.1-bound process forwards to nothing. What decides your exposure is how you publish the port, not that bind.

Because the bind is open, first boot mints an API token, and every /api/* request must carry it:

bash
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/health

For a browser, use server.proxyAuth

A bearer token covers scripts and channels. It cannot cover the dashboard: a token rides on an Authorization header, EventSource cannot send headers, and the dashboard depends on SSE for chat and the event feed.

server.proxyAuth is the browser-facing credential. It accepts a session cookie, which SSE does carry.

yaml
server:
  host: 0.0.0.0
  proxyAuth:
    enabled: true
    password: ${TAI_DASHBOARD_PASSWORD}

Restart and the dashboard shows a login form. A correct password mints an HttpOnly, SameSite=Lax cookie good for a week. Secure is added when the request arrived over TLS, read from x-forwarded-proto when a proxy terminates it.

The password doubles as a bearer, so one credential covers both surfaces:

bash
curl -H "Authorization: Bearer $PASSWORD" https://tai.example.com/api/health

Set authToken alongside it to give scripts their own secret. Both are accepted while proxyAuth is on.

Rotating the password invalidates every issued session, because the session signature is keyed by the password. Failed logins are throttled per client IP, ten per fifteen minutes, read from x-forwarded-for so one attacker cannot lock out everyone behind the same proxy. Enabling proxyAuth with an empty password fails every request closed rather than falling open.

Put TLS in front before exposing it. The password crosses the wire on login, and the cookie on every request after.

Choosing an exposure

OptionDashboardSetup
proxyAuth plus TLSworksa password in config, a proxy terminating TLS
Loopback plus an SSH tunnelworksssh -L 3000:127.0.0.1:3000 user@host, no config
Authenticating reverse proxyworksCaddy or nginx basic-auth, Cloudflare Access, Tailscale Serve
authToken onlyAPI onlyscripts and channels work, browsers do not

Do not publish the port with no credential at all. That serves the whole dashboard, unauthenticated, to anyone who finds the IP.

Reverse proxy

Caddy terminating TLS in front of proxyAuth:

caddyfile
tai.example.com {
    reverse_proxy 127.0.0.1:3000
}

Caddy sets x-forwarded-proto: https, so the session cookie is issued with Secure.

To let the proxy own authentication instead, add basic_auth and leave proxyAuth off:

caddyfile
tai.example.com {
    basic_auth {
        you $2a$14$...          # caddy hash-password
    }
    reverse_proxy 127.0.0.1:3000
}

Keep the container published to 127.0.0.1:3000 either way, so the proxy is the only way in. When the proxy authenticates, drop TAI's own credential (--no-auth-token at init, or clear server.authToken) so requests pass through. Two login prompts for one dashboard help nobody.

Day to day

bash
docker compose logs -f                      # tail
docker compose exec tai tai edit            # TUI config editor (needs a TTY)
docker compose exec tai tai --list-agents
docker compose exec tai tai plugin install @tailored-ai/provider-anthropic
docker compose restart                      # after editing config.yaml by hand

Config changes are watched and hot-reloaded, but reload has been unreliable in practice. Check generation in /api/health to confirm one landed, and restart if the number did not move.

Backups

Stop the container first. SQLite in WAL mode leaves agent.db-wal and agent.db-shm beside the database, and copying a live set can capture a torn state.

bash
docker compose stop
docker run --rm -v tai_tai-data:/data -v "$PWD:/backup" alpine \
  tar czf /backup/tai-backup-$(date +%F).tar.gz -C /data .
docker compose start

Restore by extracting into an empty volume before the first start.

Upgrades

bash
git pull && docker compose build && docker compose up -d

The volume is untouched and migrations run at startup. Take a backup first. There is no downgrade once migrations have run.

What the container can and cannot do

The agent's exec tool runs inside the container, which is a stronger boundary than the laptop default where it runs against your real home directory. Keep tools.exec.allowedCommands tight anyway.

sandbox: docker (per-agent container isolation) does not work in this image. It needs a Docker socket, and mounting one hands the agent root on the host. Use the default host sandbox and rely on the container itself.

Not included: Playwright, md-to-pdf, and a model server. TAI talks to a model over HTTP, and the browser tools ship in the trusted-actions image.

The browser and md_to_pdf tools are still registered. Each imports its engine lazily, so calling one returns the install instruction instead of failing at startup:

playwright is not installed. Run `npm install playwright && npx playwright
install chromium` to enable the browser tool.

What stays: @modelcontextprotocol/sdk for MCP, plus pdf-parse and tesseract.js for the extract_document tool. Those three back features that work the moment you boot the image.

Keeping it small

The image is ~670 MB, of which 136 MB is TAI and its dependencies. It was 886 MB until a dependency audit found that pnpm deploy --prod drops devDependencies but keeps peerDependencies marked optional. A test runner, a TypeScript compiler and a browser driver were shipping to every self-hoster, none of them reachable from the entrypoint.

A CI guard now fails the build when a first-party package declares a build tool under dependencies, a prune step drops development-time optional peers, and the publish workflow enforces a size ceiling.

Deploying to a cloud provider

tai deploy drives this. TAI ships a docker target for the local machine, and cloud providers register through the same seam as plugins.

bash
tai deploy list
tai deploy plan docker      # describe what `up` would do, change nothing
tai deploy up docker

For AWS, install @tailored-ai/deploy-aws.

A single VM with a persistent disk is the right target: EC2 or Lightsail, Compute Engine, a Droplet, a Hetzner box. Install Docker, clone, docker compose up -d, put Caddy in front.

Serverless container platforms (Fargate, Cloud Run, App Runner) fit badly. They assume a stateless replaceable container. SQLite on a network filesystem breaks WAL locking, and scale-to-zero stops cron and autopilot, which is most of what a personal agent does while you are not looking at it.

1 vCPU and 1 GB runs TAI itself comfortably. Anything more is for a co-located model server.