# Self-host Pantheon

> Run the API, worker, migrations, and PostgreSQL with Docker Compose.

## Prerequisites

- Install Docker Engine with Docker Compose.
- Clone Pantheon and open the `api` directory.
- Create an OpenRouter API key for model calls.
- Create a Daytona API key only when an agent requires a sandbox.

## Create the Compose file

Save this file as `docker-compose.yaml` in the `api` directory.

```yaml title="docker-compose.yaml"
services:
  postgres:
    image: pgvector/pgvector:pg15
    environment:
      POSTGRES_USER: ai_user
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: pantheon_db
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ai_user -d pantheon_db"]
      interval: 2s
      timeout: 3s
      retries: 15

  migrate:
    build:
      context: .
      target: runtime
    image: pantheon:latest
    env_file: [.env]
    environment:
      POSTGRES_DATABASE_URL: postgresql+psycopg2://ai_user:secret@postgres:5432/pantheon_db
    depends_on:
      postgres:
        condition: service_healthy
    command: alembic upgrade head
    restart: "no"

  api:
    build:
      context: .
      target: runtime
    image: pantheon:latest
    env_file: [.env]
    environment:
      POSTGRES_DATABASE_URL: postgresql+psycopg2://ai_user:secret@postgres:5432/pantheon_db
    ports:
      - "8000:8000"
    depends_on:
      migrate:
        condition: service_completed_successfully
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000
    restart: unless-stopped

  worker:
    image: pantheon:latest
    env_file: [.env]
    environment:
      POSTGRES_DATABASE_URL: postgresql+psycopg2://ai_user:secret@postgres:5432/pantheon_db
    depends_on:
      migrate:
        condition: service_completed_successfully
    command: python -m app.modules.workers.runtime_worker
    restart: unless-stopped

volumes:
  pgdata:
```

| Service  | Purpose                                                     |
| -------- | ----------------------------------------------------------- |
| postgres | Stores Pantheon data in the `pgdata` volume.                |
| migrate  | Applies Alembic migrations before the API or worker starts. |
| api      | Serves the REST API on host port 8000.                      |
| worker   | Claims queued runs and executes agents.                     |

## Set the environment

Save the required values in `api/.env`. Restrict access to this file.

```bash title=".env"
OPENROUTER_API_KEY=replace-with-your-openrouter-secret
DAYTONA_API_KEY=
POSTGRES_DATABASE_URL=postgresql+psycopg2://ai_user:secret@postgres:5432/pantheon_db
PANTHON_BOOTSTRAP_ADMIN_KEY=ak_replace-with-a-long-random-secret
PANTHEON_SECRETS_ENCRYPTION_KEY=replace-with-a-generated-fernet-key
```

| Variable                          | Purpose                                                                                                                                                                 | Secret |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| `OPENROUTER_API_KEY`              | Authorizes model calls.                                                                                                                                                 | Yes    |
| `DAYTONA_API_KEY`                 | Creates sandbox environments. Leave it empty when `requires_sandbox` is `false`.                                                                                        | Yes    |
| `POSTGRES_DATABASE_URL`           | Connects each Pantheon service to PostgreSQL.                                                                                                                           | Yes    |
| `PANTHON_BOOTSTRAP_ADMIN_KEY`     | Seeds the first admin key when the API starts.                                                                                                                          | Yes    |
| `PANTHEON_SECRETS_ENCRYPTION_KEY` | Encrypts [workspace secrets](https://pantheon-todo.alethiconsulting.com/docs/secrets). Generate a Fernet key. Lose it and every stored credential must be stored again. | Yes    |

## Start Pantheon

### Build and start

Compose waits for PostgreSQL, runs migrations, then starts the API and worker.

### Check each service

The migration service should exit successfully. The API and worker should remain running.

### Protect the admin key

The API stores its hash on first start. Keep the raw bootstrap value in a secret manager.

```bash title="Terminal"
cd /path/to/pantheon/api
docker compose up --build -d
docker compose ps
docker compose logs migrate api worker
```

## Create the first tenant

### Admin API

Use the bootstrap admin key to create a tenant and its first API key.

```bash title="Terminal"
export PANTHEON_BASE_URL="http://localhost:8000"
export PANTHEON_API_KEY="PANTHEON_API_KEY"

TENANT_JSON=$(curl -sS -X POST "$PANTHEON_BASE_URL/v1/admin/tenants" \
  -H "Authorization: Bearer $PANTHEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme","slug":"acme"}')
TENANT_ID=$(printf '%s' "$TENANT_JSON" | jq -r '.id')

KEY_JSON=$(curl -sS -X POST "$PANTHEON_BASE_URL/v1/admin/tenants/$TENANT_ID/api-keys" \
  -H "Authorization: Bearer $PANTHEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Development","permissions":["*"]}')
printf '%s' "$KEY_JSON" | jq '{id, tenant_id, name, permissions, key_preview, raw_key, created_at}'
```

The key creation response contains `raw_key` once.

```json title="Selected JSON"
{
  "id": "key_123",
  "tenant_id": "tenant_123",
  "name": "Development",
  "permissions": ["*"],
  "key_preview": "pk_example...",
  "raw_key": "PANTHEON_API_KEY",
  "created_at": "2026-09-04T12:00:00Z"
}
```

### Dashboard

After the auth release, open `/dashboard/sign-up`.

The dashboard talks to the API that served it, so no base URL setup is needed.

Sign up with an email, password, and organization name. Then create a key on the keys page.

Read [Get an API key](https://pantheon-todo.alethiconsulting.com/docs/get-a-key) for the complete flow.

## Upgrade

Back up PostgreSQL first. Then pull the source, rebuild images, migrate, and restart services.

```bash title="Terminal"
cd /path/to/pantheon/api
git pull --ff-only
docker compose build --pull
docker compose run --rm migrate
docker compose up -d --remove-orphans
```

## Back up PostgreSQL

Create a compressed dump outside the database container.

```bash title="Terminal"
cd /path/to/pantheon/api
docker compose exec -T postgres \
  pg_dump -U ai_user -d pantheon_db -Fc > pantheon.dump
```

Store the dump away from the Docker host. Test restoration on a separate database.

## Troubleshooting

| Symptom                        | Cause                                                    | What to do                                                                     |
| ------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Migrations wait for a lock.    | Idle database connections still hold the migration lock. | Stop the API and worker. Close idle connections, then run the migration again. |
| The API cannot bind port 8000. | Another process uses the host port.                      | Stop that process or change the left side of `8000:8000`.                      |
| Sandbox creation fails.        | The Daytona account reached its quota.                   | Increase the quota or use an agent with `requires_sandbox` set to `false`.     |

## Pitfalls

> **Warning**
>
> - **The migration service keeps running:** Inspect its logs before starting the API manually.
> - **The worker cannot call a model:** Set OPENROUTER_API_KEY in the shared .env file and restart the worker.
> - **A sandbox request uses mock mode:** Set DAYTONA_API_KEY before using an agent that requires a sandbox.
> - **Storing a secret returns 503 SECRETS_NOT_CONFIGURED:** Set PANTHEON_SECRETS_ENCRYPTION_KEY to a valid Fernet key and restart the API. Generate one with python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'.
