Self-hosting
Run it without Docker
Run Oatmilk's processes directly on a Linux machine, with PostgreSQL, PostgREST, Storage, Redis and Caddy.
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
| Piece | Version |
|---|---|
| Node.js | 24 |
| Bun | 1.3 or newer |
| PostgreSQL | 15 or newer, with pgcrypto and uuid-ossp |
| Redis or Valkey | 6 or newer |
| PostgREST | 14 |
| Supabase Storage | 1.74 |
| Caddy | 2 |
Create an empty database named oatmilk, and keep its admin connection string.
2. Get Oatmilk and write its settings
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:
set -a; source self-host/.env; set +a3. Build
export NEXT_PUBLIC_OATMILK_AUTH_PROVIDER=better-auth OATMILK_DEPLOYMENT=self-hosted SKIP_NEXT_TYPECHECK=1
bun run build:eve && bun run buildThe build takes about 10 minutes and needs about 8 GB of memory. Build again after every update.
4. Prepare the database
bun scripts/self-host/database.ts bootstrapIt 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:
| Service | Setting | Value |
|---|---|---|
| PostgREST | PGRST_DB_URI | $OATMILK_POSTGREST_DB_URI |
| PostgREST | PGRST_DB_SCHEMAS, PGRST_DB_EXTRA_SEARCH_PATH | public, and public,extensions |
| PostgREST | PGRST_DB_ANON_ROLE, PGRST_JWT_SECRET | anon, and $OATMILK_JWT_SECRET |
| PostgREST | PGRST_DB_MAX_ROWS, PGRST_SERVER_PORT | 1000, and 3100 |
| Storage | DATABASE_URL, AUTH_JWT_SECRET | $OATMILK_DATABASE_URL, and $OATMILK_JWT_SECRET |
| Storage | ANON_KEY, SERVICE_KEY | $OATMILK_ANON_KEY, and $ACCOUNTING_SUPABASE_SERVICE_ROLE_KEY |
| Storage | STORAGE_BACKEND, FILE_STORAGE_BACKEND_PATH | file, and a folder such as /var/lib/oatmilk/files |
| Storage | TENANT_ID, REGION, FILE_SIZE_LIMIT | oatmilk, local, and 104857600 |
| Storage | ENABLE_IMAGE_TRANSFORMATION, PORT | false, 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:
bun scripts/self-host/database.ts migrateRun 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:
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/CaddyfileFrom 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
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.tsserve.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:
[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.targetThen, with self-host/.env loaded, make your account (the password comes from standard input) and open https://books.example.com:
echo "$PASSWORD" | bun scripts/self-host/accounts.ts add --email you@example.com --first-name AdaUpdating
Pull, build, migrate and restart, in that order:
git pull && bun install --frozen-lockfile
bun run build:eve && bun run build
bun scripts/self-host/database.ts migrate
sudo systemctl restart oatmilk