Self-hosting
Run TAI on a machine that isn't your laptop. The supported shape is one container and one volume.
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:
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.
| Variable | Default | |
|---|---|---|
TAI_MODEL | none | Required. Model name your provider serves. |
TAI_BASE_URL | http://localhost:11434/v1 | OpenAI-wire endpoint. |
TAI_PROVIDER | openai_compatible | Any registered provider id. |
TAI_API_KEY | none | Written to .env, referenced from config. |
TAI_SERVER_HOST | 0.0.0.0 in the image | Bind inside the container. |
TAI_SERVER_PORT | 3000 | |
TAI_AUTH_TOKEN | generated | Bearer token for /api/*. |
TAI_HOME | /data in the image | The 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:
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:
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:
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.
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:
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
| Option | Dashboard | Setup |
|---|---|---|
proxyAuth plus TLS | works | a password in config, a proxy terminating TLS |
| Loopback plus an SSH tunnel | works | ssh -L 3000:127.0.0.1:3000 user@host, no config |
| Authenticating reverse proxy | works | Caddy or nginx basic-auth, Cloudflare Access, Tailscale Serve |
authToken only | API only | scripts 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:
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:
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
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.
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
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.
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.