Self-hosting

Run it without Docker

Run Oatmilk's processes directly on a Linux machine, with PostgreSQL, PostgREST, Storage, Redis and Caddy.

60 minutes · Advanced

The Docker stack is the tested way to run Oatmilk, and the easiest to update. Where Docker isn't an option, the same pieces run directly on one Linux machine: Oatmilk's image only runs two commands, and everything else is a standard service.

1. Install the services

PieceVersion
Node.js24
Bun1.3 or newer
PostgreSQL15 or newer, with pgcrypto and uuid-ossp
Redis or Valkey6 or newer
PostgREST14
Supabase Storage1.74
Caddy2

Create an empty database named oatmilk, and keep its admin connection string.

2. Get Oatmilk and write its settings

Shell
git clone https://github.com/AGI-Ventures-Canada/oatmilk.git && cd oatmilk
bun install --frozen-lockfile
bun run self-host init --server --domain books.example.com \
  --database-url 'postgres://postgres:…@127.0.0.1:5432/oatmilk' \
  --redis-url 'redis://:…@127.0.0.1:6379'

init doesn't need Docker. It writes self-host/.env with fresh secrets. Add your AI and email settings to it, then load it into your shell:

Shell
set -a; source self-host/.env; set +a

3. Build

Shell
export NEXT_PUBLIC_OATMILK_AUTH_PROVIDER=better-auth OATMILK_DEPLOYMENT=self-hosted SKIP_NEXT_TYPECHECK=1
bun run build:eve && bun run build

The build takes about 10 minutes and needs about 8 GB of memory. Build again after every update.

4. Prepare the database

Shell
bun scripts/self-host/database.ts bootstrap

It creates the roles and schemas Oatmilk's migrations expect, including the authenticator role PostgREST signs in as.

5. Start PostgREST and Storage

Give them the same settings as self-host/compose.yaml, with values from self-host/.env:

ServiceSettingValue
PostgRESTPGRST_DB_URI$OATMILK_POSTGREST_DB_URI
PostgRESTPGRST_DB_SCHEMAS, PGRST_DB_EXTRA_SEARCH_PATHpublic, and public,extensions
PostgRESTPGRST_DB_ANON_ROLE, PGRST_JWT_SECRETanon, and $OATMILK_JWT_SECRET
PostgRESTPGRST_DB_MAX_ROWS, PGRST_SERVER_PORT1000, and 3100
StorageDATABASE_URL, AUTH_JWT_SECRET$OATMILK_DATABASE_URL, and $OATMILK_JWT_SECRET
StorageANON_KEY, SERVICE_KEY$OATMILK_ANON_KEY, and $ACCOUNTING_SUPABASE_SERVICE_ROLE_KEY
StorageSTORAGE_BACKEND, FILE_STORAGE_BACKEND_PATHfile, and a folder such as /var/lib/oatmilk/files
StorageTENANT_ID, REGION, FILE_SIZE_LIMIToatmilk, local, and 104857600
StorageENABLE_IMAGE_TRANSFORMATION, PORTfalse, and 5000

For an S3-compatible bucket instead of a folder, copy the GLOBAL_S3_* and AWS_* settings from self-host/compose.yaml.

6. Apply the migrations

Once Storage has started, which creates its own tables:

Shell
bun scripts/self-host/database.ts migrate

Run it again after every update.

7. Start Caddy

self-host/Caddyfile serves the site and joins PostgREST and Storage under one internal address, as Supabase does. Point it at the local processes:

Shell
OATMILK_DOMAIN=books.example.com \
OATMILK_APP_UPSTREAM=127.0.0.1:3000 \
OATMILK_STORAGE_UPSTREAM=127.0.0.1:5000 \
OATMILK_POSTGREST_UPSTREAM=127.0.0.1:3100 \
caddy run --config self-host/Caddyfile

From outside, only signed file links under /_storage/ reach Storage, and nothing reaches PostgREST. Keep ports 3000, 3100, 5000 and 8000 closed to the internet.

8. Start Oatmilk

Shell
export ACCOUNTING_APP_URL="$OATMILK_PUBLIC_URL"
export ACCOUNTING_SUPABASE_URL=http://127.0.0.1:8000
export ACCOUNTING_SUPABASE_PUBLIC_URL="$OATMILK_PUBLIC_URL/_storage"
bun scripts/self-host/serve.ts

serve.ts starts the web app on PORT (3000) and every AI agent on its own port from 4274 up, and restarts any that stop. Keep it running with a systemd service:

/etc/systemd/system/oatmilk.service
[Unit]
Description=Oatmilk
After=network-online.target postgresql.service redis-server.service

[Service]
User=oatmilk
WorkingDirectory=/opt/oatmilk
EnvironmentFile=/opt/oatmilk/self-host/.env
Environment=ACCOUNTING_SUPABASE_URL=http://127.0.0.1:8000
ExecStart=/bin/sh -c 'ACCOUNTING_APP_URL="$OATMILK_PUBLIC_URL" ACCOUNTING_SUPABASE_PUBLIC_URL="$OATMILK_PUBLIC_URL/_storage" exec /usr/local/bin/bun scripts/self-host/serve.ts'
Restart=always

[Install]
WantedBy=multi-user.target

Then, with self-host/.env loaded, make your account (the password comes from standard input) and open https://books.example.com:

Shell
echo "$PASSWORD" | bun scripts/self-host/accounts.ts add --email you@example.com --first-name Ada

Updating

Pull, build, migrate and restart, in that order:

Shell
git pull && bun install --frozen-lockfile
bun run build:eve && bun run build
bun scripts/self-host/database.ts migrate
sudo systemctl restart oatmilk