# Run it without Docker

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

Source: https://app.getoatmilk.com/docs/self-hosting/without-docker

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](https://bun.sh) | 1.3 or newer |
| PostgreSQL | 15 or newer, with `pgcrypto` and `uuid-ossp` |
| Redis or Valkey | 6 or newer |
| [PostgREST](https://postgrest.org) | 14 |
| [Supabase Storage](https://github.com/supabase/storage) | 1.74 |
| [Caddy](https://caddyserver.com) | 2 |

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

## 2. Get Oatmilk and write its settings

```bash
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:

```bash
set -a; source self-host/.env; set +a
```

## 3. Build

```bash
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

```bash
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`:

| 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:

```bash
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:

```bash
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

```bash
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:

```ini title="/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`:

```bash
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:

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