# Local development

> Dev Compose stack, hot-reload per service, host-side run commands, infra-only mode, and port map through the nginx gateway.

- 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

- `docs/guides/local-development.md`
- `deploy/docker-compose.dev.yml`
- `deploy/.env.dev.example`
- `services/api/Makefile`
- `apps/web/package.json`
- `services/realtime/package.json`

---

---
title: "Local development"
description: "Dev Compose stack, hot-reload per service, host-side run commands, infra-only mode, and port map through the nginx gateway."
---

Paca's contributor stack is defined in `deploy/docker-compose.dev.yml` (project name `paca-dev`). One Compose file runs PostgreSQL, Valkey, MinIO, the Go API, Bun web and realtime services, the Python ai-agent worker, and an nginx gateway on port 80. Application services bind-mount the monorepo source tree and reload on change; the gateway is the single browser entrypoint at `http://localhost`.

## Prerequisites

<Note>
Docker with the Compose plugin is the only hard requirement for the full containerized stack. Each application service builds from a `Dockerfile.dev` in its own directory.
</Note>

For host-side service development (optional), install:

| Tool | Used by |
|---|---|
| Go 1.23+ | `services/api` |
| [Bun](https://bun.sh) | `apps/web`, `services/realtime` |
| Python 3.12+ with [uv](https://docs.astral.sh/uv/) | `services/ai-agent` |
| [air](https://github.com/air-verse/air) (optional) | API hot-reload on the host |

## Start the full dev stack

<Steps>
<Step title="Launch containers">

```bash
docker compose -f deploy/docker-compose.dev.yml up -d
```

</Step>
<Step title="Open the app">

Browse to `http://localhost`. The nginx gateway on port 80 proxies all browser traffic to the correct upstream.

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

```bash
curl -s http://localhost/api/healthz
```

Expect a successful response from the Go API through the gateway.

</Step>
</Steps>

Stop the stack:

```bash
docker compose -f deploy/docker-compose.dev.yml down
```

Remove data volumes (destructive — wipes Postgres, Valkey, and MinIO data):

```bash
docker compose -f deploy/docker-compose.dev.yml down -v
```

## Dev stack services

| Service | Technology | Container port | Host exposure | Hot-reload |
|---|---|---|---|---|
| `gateway` | nginx 1.27 | 80 | **80** | — |
| `web` | React + TanStack Start + Vite | 3000 | via gateway | Vite HMR (`bun run dev`) |
| `api` | Go + Gin | 8080 | via gateway | [air](https://github.com/air-verse/air) |
| `realtime` | Bun + Socket.IO | 3001 | via gateway (`/ws/`) | `bun --watch` |
| `ai-agent` | Python + FastAPI + OpenHands | 8080 | **8082** | uvicorn `--reload` |
| `postgres` | postgres:16-alpine | 5432 | **5432** | — |
| `valkey` | valkey/valkey:8-alpine | 6379 | **6379** | — |
| `minio` | minio/minio | 9000 / 9001 | **9000** / **9001** | — |

<Info>
The `web` and `realtime` containers are not published on host ports directly. Browser clients reach them only through the gateway at port 80.
</Info>

## Gateway routing

The gateway config at `deploy/nginx/gateway.conf` is the public entrypoint. All browser clients (`apps/web` API client and Socket.IO client) use `window.location.origin`, so paths below must resolve on the same host the user opens.

```mermaid
flowchart LR
  Browser["Browser :80"]
  GW["gateway nginx"]
  API["api :8080"]
  RT["realtime :3001"]
  WEB["web :3000"]
  MINIO["minio :9000"]

  Browser --> GW
  GW -->|"/api/*"| API
  GW -->|"/ws/*"| RT
  GW -->|"/storage/*"| MINIO
  GW -->|"/plugins/*"| Plugins["plugins/local/frontend"]
  GW -->|"/plugins-mcp/*"| MCP["plugins/local/mcp"]
  GW -->|"/"| WEB
```

| Gateway path | Upstream | Behavior |
|---|---|---|
| `/api/` | `api:8080` | Forwards `/api/v1/…` and `/api/healthz` unchanged |
| `/ws/` | `realtime:3001` | Strips `/ws/` prefix; Socket.IO path is `/ws/socket.io` |
| `/storage/` | `minio:9000` | Presigned upload/download URLs rewritten to `STORAGE_PUBLIC_URL` |
| `/plugins/` | Static volume | Local plugin frontend bundles (`remoteEntry.js`) |
| `/plugins-mcp/` | Static volume | MCP plugin ESM bundles |
| `/` | `web:3000` | SPA + Vite HMR WebSocket upgrade |

Socket.IO client configuration in `apps/web` connects to `window.location.origin` with path `/ws/socket.io` and `withCredentials: true`.

## Hot-reload per service

### API (`services/api`)

Inside the container, the `api` service runs `air` with `.air.toml` configured for Docker Desktop bind mounts: `poll = true` at 500 ms because inotify events do not cross the macOS VM boundary. `GOFLAGS=-p=1` limits parallel compilation to avoid OOM kills.

On the host, `make run` executes `go run ./cmd/api/main.go` (no file watcher). For container-equivalent reload, run `air` from `services/api` after installing it.

### Web (`apps/web`)

The container runs `bun install && bun run dev --host 0.0.0.0 --port 3000`. When `DOCKER=true`, Vite enables filesystem polling and sets `hmr.clientPort: 3000` so HMR works through the gateway.

### Realtime (`services/realtime`)

The container runs `bun install && bun run --watch src/index.ts`. The `dev` script in `package.json` is identical: `bun run --watch src/index.ts`.

### AI agent (`services/ai-agent`)

`Dockerfile.dev` starts `uvicorn src.main:app --host 0.0.0.0 --port 8080 --reload`. The service is mapped to host port **8082** (`8082:8080`). Default settings port is 8080.

## Infra-only mode

Run only shared dependencies when you want application services on the host:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d postgres valkey
```

PostgreSQL applies migrations automatically on first start — SQL files from `services/api/migrations/` are mounted at `/docker-entrypoint-initdb.d`.

To include object storage without running app services:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d postgres valkey minio
```

<Warning>
Without the gateway, a host-run `apps/web` dev server on port 3000 cannot reach `/api` or `/ws` on the same origin. Use the full compose stack (or run all app services and access through port 80) for end-to-end browser testing.
</Warning>

## Host-side run commands

Copy environment files on first setup and point connection strings at `localhost` instead of Docker service names.

### API

```bash
cd services/api
cp .env.example .env   # DATABASE_URL=postgres://paca:paca@localhost:5432/paca
make run               # or: air
```

<ParamField body="DATABASE_URL" type="string" required>
`postgres://paca:paca@localhost:5432/paca?sslmode=disable` when using compose Postgres.
</ParamField>

<ParamField body="REDIS_URL" type="string" required>
`redis://localhost:6379/0`
</ParamField>

<ParamField body="STORAGE_ENDPOINT" type="string">
`localhost:9000` with `STORAGE_PUBLIC_URL=http://localhost/storage` when MinIO runs in compose.
</ParamField>

### Web

```bash
cd apps/web
bun install
bun run dev            # Vite at http://localhost:3000
```

### Realtime

```bash
cd services/realtime
bun install
bun run dev
```

Required environment variables:

<ParamField body="API_URL" type="string" required>
Internal API base URL, e.g. `http://localhost:8080` (bypasses nginx).
</ParamField>

<ParamField body="REDIS_URL" type="string" required>
`redis://localhost:6379/0`
</ParamField>

<ParamField body="CORS_ORIGINS" type="string">
Comma-separated allowed origins. Default: `http://localhost:3000`.
</ParamField>

### AI agent

```bash
cd services/ai-agent
uv sync
cp .env.example .env   # adjust hostnames to localhost
uv run uvicorn src.main:app --reload --port 8080
```

Update `.env` for host-side infra:

| Variable | Container default | Host-side value |
|---|---|---|
| `VALKEY_URL` | `redis://valkey:6379/0` | `redis://localhost:6379/0` |
| `DATABASE_URL` | `postgres://paca:paca@postgres:5432/paca` | `postgres://paca:paca@localhost:5432/paca` |
| `API_BASE_URL` | `http://api:8080` | `http://localhost:8080` |
| `GATEWAY_BASE_URL` | `http://gateway` | `http://localhost` |
| `PACA_API_KEY` | `dev-agent-api-key-change-in-production` | Must match `AGENT_API_KEY` on the API |

<Note>
`apps/mcp` is stateless. Run it with `npx @paca-ai/paca-mcp` pointed at the running API — see the Connect MCP page.
</Note>

## Exposing through a tunnel or reverse proxy

Copy `deploy/.env.dev.example` to `deploy/.env.dev` and set public host variables:

```bash
cp deploy/.env.dev.example deploy/.env.dev
# Edit PUBLIC_HOST and VITE_ALLOWED_HOST
docker compose --env-file deploy/.env.dev -f deploy/docker-compose.dev.yml up -d
```

<ParamField body="PUBLIC_HOST" type="string">
Full public URL without trailing slash, e.g. `https://abc123.ngrok-free.app`. Drives `STORAGE_PUBLIC_URL`, `PUBLIC_URL`, and `CORS_ORIGINS` in compose.
</ParamField>

<ParamField body="VITE_ALLOWED_HOST" type="string">
Hostname only (no scheme or port). Passed to Vite `server.allowedHosts` so the dev server accepts tunnel requests.
</ParamField>

## Migrations

SQL migrations live in `services/api/migrations/` and run lexicographically. Fresh Postgres containers apply them at init.

To apply manually against a running database:

```bash
cd services/api
DATABASE_URL=postgres://paca:paca@localhost:5432/paca?sslmode=disable make migrate-up
```

Destructive reset:

```bash
make migrate-down   # drops and recreates public schema
```

## Local plugin stores

The dev compose file mounts `plugins/local/` into API and gateway containers:

| Mount | API path | Gateway path | Contents |
|---|---|---|---|
| `plugins/local/backend` | `/plugins` | — | WASM binaries + SQL migrations |
| `plugins/local/frontend` | `/plugins-frontend` | `/var/www/plugins` | Module-federation `remoteEntry.js` |
| `plugins/local/mcp` | `/plugins-mcp` | `/var/www/plugins-mcp` | MCP ESM bundles |

Backend plugin artifacts are API-only; the gateway serves frontend and MCP bundles at `/plugins/` and `/plugins-mcp/`.

## Default dev credentials

| Resource | Value |
|---|---|
| App login | `admin` / `adminpassword` |
| PostgreSQL | `paca:paca@localhost:5432/paca` |
| MinIO console | `minioadmin` / `minioadmin` at `http://localhost:9001` |
| Agent API key | `dev-agent-api-key-change-in-production` (`AGENT_API_KEY` on API, `PACA_API_KEY` on ai-agent) |
| Internal API key | `dev-internal-key-change-in-production` (ai-agent → API) |
| JWT secret | `dev-change-in-production` |

<Warning>
These credentials are intentionally weak. Never use them outside local development.
</Warning>

## Architecture notes

- `services/api` owns persistent state and publishes domain events to Valkey Streams.
- `services/realtime` consumes those events and fans them out over Socket.IO.
- `services/ai-agent` reads agent trigger events from a separate Valkey Stream and manages Docker containers for OpenHands conversations (requires `/var/run/docker.sock`).
- Compose caches Go build output (`go_build_cache`) and Bun `node_modules` in named volumes so restarts do not cold-start dependency installs.

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
First successful run: admin login, project creation, API key, and health checks.
</Card>
<Card title="Platform architecture" href="/platform-architecture">
Service boundaries, Valkey event decoupling, and gateway routing model.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Environment variables across API, web, realtime, and ai-agent.
</Card>
<Card title="Contributing" href="/contributing">
Repository layout, PR checklist, and test surfaces.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Health check failures, Valkey connectivity, storage misconfiguration, and MCP errors.
</Card>
</CardGroup>
