# Deploy production

> Production Docker Compose topology, nginx gateway, storage backends (MinIO vs S3), secret generation, and scaling optional services.

- Repository: Paca-AI/paca
- GitHub: https://github.com/Paca-AI/paca
- Human docs: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25
- Complete Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/llms-full.txt

## Source Files

- `deploy/README.md`
- `deploy/docker-compose.prod.yml`
- `deploy/.env.production.example`
- `deploy/nginx/gateway.conf`
- `scripts/install.sh`
- `services/api/docker-entrypoint.sh`

---

---
title: "Deploy production"
description: "Production Docker Compose topology, nginx gateway, storage backends (MinIO vs S3), secret generation, and scaling optional services."
---

Production deployment uses `deploy/docker-compose.prod.yml` (published as `docker-compose.yml` on GitHub releases): pre-built images from Docker Hub, a single nginx gateway on port 80, bundled PostgreSQL and Valkey, optional MinIO or AWS S3 for attachments, and optional `ai-agent`. Schema migrations are embedded in the API binary and run automatically on every API startup.

## Topology

The compose project name is `paca`. Only the `gateway` service publishes a host port (`GATEWAY_PORT`, default `80`). All browser and API traffic enters through nginx; internal services communicate on the Docker network.

```mermaid
flowchart TB
    subgraph public["Public entry"]
        GW["gateway<br/>nginx:1.27-alpine"]
    end

    subgraph app["Application services"]
        WEB["web<br/>pacaai/paca-web"]
        API["api<br/>pacaai/paca-api"]
        RT["realtime<br/>pacaai/paca-realtime"]
        AG["ai-agent<br/>pacaai/paca-ai-agent<br/>(optional)"]
    end

    subgraph data["Stateful services"]
        PG["postgres:16-alpine<br/>(optional)"]
        VK["valkey/valkey:8-alpine"]
        MINIO["minio/minio<br/>(optional)"]
    end

    Client["Clients / browsers"] --> GW
    GW -->|"/"| WEB
    GW -->|"/api/"| API
    GW -->|"/ws/"| RT
    GW -->|"/storage/"| MINIO
    GW -->|"/plugins/"| API
    GW -->|"/plugins-mcp/"| API

    API --> PG
    API --> VK
    API --> MINIO
    RT --> VK
    RT --> API
    AG --> PG
    AG --> VK
    AG --> API
    AG -.->|"/var/run/docker.sock"| HostDocker["Host Docker daemon"]
```

### Service inventory

| Service | Image | Host port | Persistent volume | Health probe |
|---|---|---|---|---|
| `gateway` | `nginx:1.27-alpine` | `${GATEWAY_PORT:-80}` | — (bind-mounts `nginx/gateway.conf`) | — |
| `api` | `${PACA_API_IMAGE:-pacaai/paca-api:latest}` | — | `backend_plugins`, `frontend_plugins`, `mcp_plugins` | `GET /api/healthz` |
| `web` | `${PACA_WEB_IMAGE:-pacaai/paca-web:latest}` | — | — | — |
| `realtime` | `${PACA_REALTIME_IMAGE:-pacaai/paca-realtime:latest}` | — | — | — |
| `postgres` | `postgres:16-alpine` | — | `postgres_data` | `pg_isready` |
| `valkey` | `valkey/valkey:8-alpine` | — | `valkey_data` | `valkey-cli ping` |
| `minio` | `minio/minio:latest` | — | `minio_data` | `mc ready local` |
| `ai-agent` | `${PACA_AI_AGENT_IMAGE:-pacaai/paca-ai-agent:latest}` | `${AI_AGENT_PORT:-8082}` | — (mounts host Docker socket) | — |

Named volumes: `postgres_data`, `valkey_data`, `minio_data`, `backend_plugins`, `frontend_plugins`, `mcp_plugins`.

<Note>
Release artifacts ship as `docker-compose.yml` and `nginx/gateway.conf`. The repository copies live under `deploy/docker-compose.prod.yml` and `deploy/nginx/gateway.conf`.
</Note>

## Deploy paths

<Tabs>
<Tab title="Install script (recommended)">

The install script downloads release artifacts, walks through database/storage/web/agent choices, generates secrets, writes `.env`, and starts the stack.

<Steps>
<Step title="Download and run">

```bash
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh
bash install.sh
```

Non-interactive mode with defaults and auto-generated secrets:

```bash
bash <(curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh)
```

Environment overrides:

| Variable | Default | Effect |
|---|---|---|
| `PACA_DIR` | `./paca` | Installation directory |
| `PACA_VERSION` | `latest` | Release tag (`v1.2.3` pins image tags) |
| `PACA_YES` | `0` | Set to `1` to skip prompts |

</Step>
<Step title="Verify">

```bash
docker compose --env-file .env ps
curl -s http://localhost/api/healthz
```

Expected health response:

```json
{"status":"ok"}
```

</Step>
</Steps>

</Tab>
<Tab title="Manual setup">

<Steps>
<Step title="Download compose and gateway config">

```bash
mkdir -p paca/nginx && cd paca
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml
curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf
```

</Step>
<Step title="Create `.env`">

Copy `deploy/.env.production.example` from the repository as a reference, then set required secrets (see [Required secrets](#required-secrets)).

</Step>
<Step title="Start the stack">

```bash
docker compose --env-file .env up -d
```

</Step>
<Step title="Verify">

```bash
curl -s "${PUBLIC_URL}/api/healthz"
docker compose --env-file .env logs api --tail 20
```

The API logs `schema migrations applied` on first healthy startup.

</Step>
</Steps>

</Tab>
</Tabs>

## Required secrets

Compose enforces `JWT_SECRET` and `ADMIN_PASSWORD` at container start. The API process additionally requires `DATABASE_URL`, `REDIS_URL`, `ADMIN_USERNAME`, `STORAGE_ACCESS_KEY_ID`, and `STORAGE_SECRET_ACCESS_KEY` (supplied by compose defaults when using bundled services).

| Secret | Generate | Used by | Notes |
|---|---|---|---|
| `JWT_SECRET` | `openssl rand -hex 32` | `api` | Signs access and refresh tokens |
| `ADMIN_PASSWORD` | Strong password | `api` | Seeds default admin on first startup |
| `POSTGRES_PASSWORD` | `openssl rand -hex 16` | `postgres`, `api` | Only when bundled PostgreSQL runs |
| `ENCRYPTION_KEY` | `openssl rand -hex 32` | `api`, `ai-agent` | Exactly 64 lowercase hex chars (32 bytes, AES-256). Required for plugin secrets and LLM key decryption |
| `AGENT_API_KEY` | `openssl rand -hex 32` | `api`, `ai-agent` (`PACA_API_KEY`) | Pre-shared service key; must match across both services |
| `INTERNAL_API_KEY` | `openssl rand -hex 32` | `ai-agent` | Authenticates ai-agent → API calls. Empty value disables agent endpoint authentication |
| `STORAGE_ACCESS_KEY_ID` | Random string | `api`, `minio` (`MINIO_ROOT_USER`) | MinIO root user when self-hosted |
| `STORAGE_SECRET_ACCESS_KEY` | Random string | `api`, `minio` (`MINIO_ROOT_PASSWORD`) | MinIO root password when self-hosted |

<Warning>
Back up `ENCRYPTION_KEY` before any database migration or restore. A different key makes existing encrypted plugin secrets and LLM API keys permanently unreadable.
</Warning>

When enabling `ai-agent`, set all four agent-related keys (`AGENT_API_KEY`, `INTERNAL_API_KEY`, `ENCRYPTION_KEY`, plus matching `PACA_API_KEY` on the agent side via compose). The install script generates these automatically when the agent is included.

## Object storage

The API uses an S3-compatible client. `STORAGE_PROVIDER=minio` (default) runs a bundled MinIO container and rewrites presigned URLs through the gateway. `STORAGE_PROVIDER=s3` connects to AWS S3 directly and returns presigned URLs without gateway rewriting.

| Aspect | MinIO (default) | AWS S3 |
|---|---|---|
| Container | `minio` started | Suppress with `--scale minio=0` |
| `STORAGE_PROVIDER` | `minio` | `s3` |
| `STORAGE_ENDPOINT` | `minio:9000` | Empty (default AWS regional endpoint) |
| `STORAGE_PUBLIC_URL` | `${PUBLIC_URL}/storage` | Empty (presigned URLs are self-contained) |
| `STORAGE_USE_SSL` | `false` | `true` |
| Path style | Forced (MinIO requirement) | Virtual-hosted (AWS default) |
| Bucket creation | API calls `EnsureBucket` on startup | Operator must create the bucket |

Presigned URL lifetimes: uploads valid for 1 hour, downloads for 15 minutes. Clients upload and download directly against the storage backend; the API stays out of the data plane.

<Tabs>
<Tab title="Self-hosted MinIO">

```bash
# .env excerpt
STORAGE_PROVIDER=minio
STORAGE_ENDPOINT=minio:9000
STORAGE_PUBLIC_URL=https://paca.example.com/storage
STORAGE_REGION=us-east-1
STORAGE_BUCKET=paca
STORAGE_ACCESS_KEY_ID=<strong-access-key>
STORAGE_SECRET_ACCESS_KEY=<strong-secret-key>
STORAGE_USE_SSL=false

docker compose --env-file .env up -d
```

The gateway proxies `/storage/` to `minio:9000`, rewriting the `Host` header to `minio:9000` so AWS Signature V4 validation succeeds on presigned requests.

</Tab>
<Tab title="AWS S3">

```bash
# .env excerpt
STORAGE_PROVIDER=s3
STORAGE_ENDPOINT=
STORAGE_PUBLIC_URL=
STORAGE_REGION=us-east-1
STORAGE_BUCKET=your-s3-bucket
STORAGE_ACCESS_KEY_ID=AKIA...
STORAGE_SECRET_ACCESS_KEY=...
STORAGE_USE_SSL=true

docker compose --env-file .env up -d --scale minio=0
```

</Tab>
</Tabs>

## Nginx gateway routing

`deploy/nginx/gateway.conf` mounts as `/etc/nginx/conf.d/default.conf` inside the `gateway` container. It is the single public entrypoint.

| Path | Upstream | Behavior |
|---|---|---|
| `/api/` | `api:8080` | Rate-limited (100 req/s per IP, burst 50). Forwards prefix as-is |
| `/ws/` | `realtime:3001` | WebSocket proxy; strips `/ws/` prefix. Client path: `/ws/socket.io` |
| `/storage/` | `minio:9000` | Variable-based proxy; `client_max_body_size 0` for large uploads |
| `/plugins/` | Volume `frontend_plugins` | Static plugin micro-frontend bundles |
| `/plugins-mcp/` | Volume `mcp_plugins` | Static MCP plugin ESM bundles |
| `/` | `web:3000` | React SPA with WebSocket upgrade support |

Security headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) apply to all responses. Default request body limit is 10 MB except on `/storage/`.

<Info>
Place TLS termination in front of the gateway (reverse proxy, load balancer, or cloud ingress). Set `PUBLIC_URL` to the HTTPS origin and `COOKIE_SECURE=true` so auth cookies require TLS.
</Info>

## Scale optional services

Suppress bundled containers with `--scale <service>=0`. Flags combine in a single `up` command.

| Goal | `.env` changes | Compose flag |
|---|---|---|
| External PostgreSQL | Set `DATABASE_URL` to managed connection string | `--scale postgres=0` |
| AWS S3 | Set `STORAGE_PROVIDER=s3` and AWS credentials | `--scale minio=0` |
| Skip AI agent | — | `--scale ai-agent=0` |
| External web / CDN | Build and host SPA separately | `--scale web=0` |

<CodeGroup>
```bash title="External PostgreSQL"
docker compose --env-file .env up -d --scale postgres=0
```

```bash title="AWS S3, no agent"
docker compose --env-file .env up -d --scale minio=0 --scale ai-agent=0
```

```bash title="External DB + S3"
docker compose --env-file .env up -d --scale postgres=0 --scale minio=0
```
</CodeGroup>

### External web hosting

When `--scale web=0`, the gateway still serves `/api/`, `/ws/`, `/storage/`, `/plugins/`, and `/plugins-mcp/`. Build the SPA from source and deploy `dist/` to your CDN or static host. Point the CDN's API proxy at `${PUBLIC_URL}/api`.

### AI agent requirements

When enabled, `ai-agent` mounts `/var/run/docker.sock` and spawns OpenHands agent-server containers (`AGENT_SERVER_IMAGE`, default `ghcr.io/openhands/agent-server:latest-python`). It exposes `${AI_AGENT_PORT:-8082}` on the host for direct access, though most traffic flows through the gateway and API.

## Startup lifecycle

On API container start, `services/api/docker-entrypoint.sh` creates and chowns plugin directories (`/plugins`, `/plugins-frontend`, `/plugins-mcp`), then execs the API binary. The bootstrap sequence:

1. Applies embedded SQL migrations (`CREATE TABLE IF NOT EXISTS` / idempotent upserts).
2. Ensures the storage bucket exists (MinIO only).
3. Loads enabled plugins, runs per-plugin DB migrations, and starts WASM modules.
4. Seeds the admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD`.

No manual migration step is required for core schema upgrades.

## Pin a release version

Set image variables in `.env` to lock to a specific release:

```bash
PACA_API_IMAGE=pacaai/paca-api:1.2.3
PACA_WEB_IMAGE=pacaai/paca-web:1.2.3
PACA_REALTIME_IMAGE=pacaai/paca-realtime:1.2.3
PACA_AI_AGENT_IMAGE=pacaai/paca-ai-agent:1.2.3
```

The install script sets these automatically from `PACA_VERSION` (strips leading `v` for Docker tags).

## Upgrade

Pull new images and recreate containers:

```bash
docker compose pull
docker compose --env-file .env up -d
```

Database migrations run automatically on the next API startup. See [Upgrade and migration](/upgrade-migration) for compose project rename volume migration (`paca-prod` → `paca`).

### Volume migration from `paca-prod`

Docker Compose namespaces volumes by project name. Existing volumes prefixed `paca-prod_` are not attached to the `paca` project automatically.

<Steps>
<Step title="Stop the old stack">

```bash
docker compose -p paca-prod --env-file .env down
```

Volumes remain on disk.

</Step>
<Step title="Copy each volume">

```bash
docker volume create paca_postgres_data
docker run --rm \
  -v paca-prod_postgres_data:/from \
  -v paca_postgres_data:/to \
  alpine sh -c "cp -av /from/. /to/"
docker volume rm paca-prod_postgres_data
```

Repeat for `minio_data`, `valkey_data`, and plugin volumes as needed.

</Step>
<Step title="Start the new stack">

```bash
docker compose --env-file .env up -d
```

</Step>
</Steps>

## Verification signals

| Check | Command | Expected |
|---|---|---|
| API health | `curl -s ${PUBLIC_URL}/api/healthz` | `{"status":"ok"}` |
| Gateway routing | `curl -sI ${PUBLIC_URL}/api/healthz` | HTTP 200 |
| Container health | `docker compose --env-file .env ps` | `api`, `postgres`, `valkey`, `minio` show `healthy` |
| Migrations | `docker compose --env-file .env logs api` | `schema migrations applied` |
| Realtime | Connect via Socket.IO at `${PUBLIC_URL}` with path `/ws/socket.io` | WebSocket upgrade succeeds |

<AccordionGroup>
<Accordion title="Common production misconfigurations">

| Symptom | Likely cause |
|---|---|
| Auth cookies not set | `COOKIE_SECURE=true` but `PUBLIC_URL` uses `http://` |
| Attachment upload fails (MinIO) | `STORAGE_PUBLIC_URL` does not match `${PUBLIC_URL}/storage` |
| Realtime disconnects | `CORS_ORIGINS` (defaults to `PUBLIC_URL`) does not match browser origin |
| Agent cannot decrypt LLM keys | `ENCRYPTION_KEY` mismatch between `api` and `ai-agent` |
| Plugin secrets unreadable after restore | `ENCRYPTION_KEY` changed from original value |

</Accordion>
</AccordionGroup>

## Key environment variables

<ParamField body="PUBLIC_URL" type="string">
Externally reachable base URL (no trailing slash). Used for plugin callbacks, CORS defaults, and MinIO `STORAGE_PUBLIC_URL` derivation.
</ParamField>

<ParamField body="GATEWAY_PORT" type="number" default="80">
Host port mapped to the nginx gateway.
</ParamField>

<ParamField body="COOKIE_SECURE" type="boolean" default="true">
Set `false` only when serving over plain HTTP with no TLS termination. Install script sets this from whether `PUBLIC_URL` starts with `https://`.
</ParamField>

<ParamField body="DATABASE_URL" type="string">
PostgreSQL connection string. Defaults to bundled `postgres` service when unset in compose.
</ParamField>

<ParamField body="REDIS_URL" type="string">
Valkey/Redis URL. Defaults to `redis://valkey:6379/0`.
</ParamField>

<ParamField body="CORS_ORIGINS" type="string">
Comma-separated origins for the realtime service. Defaults to `PUBLIC_URL`.
</ParamField>

<ParamField body="PLUGINS_STORE" type="string" default="local">
`local` reads WASM from filesystem volumes; `s3` reads from the configured `STORAGE_*` bucket under `PLUGINS_S3_PREFIX`.
</ParamField>

For the full variable catalog across all services, see [Configuration reference](/configuration-reference).

## Related pages

<CardGroup>
<Card title="Installation" href="/installation">
Prerequisites, install script overview, and first-time secret requirements.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey event decoupling, and gateway routing context.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Complete environment variable catalog for API, web, realtime, and ai-agent.
</Card>
<Card title="Upgrade and migration" href="/upgrade-migration">
Image pulls, automatic DB migrations, and volume migration procedures.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, storage misconfiguration, and auth cookie issues.
</Card>
</CardGroup>
