# PRD: Infrastructure & Deployment

**Scope:** Docker Compose, NATS, MinIO, PostgreSQL, nginx, service topology
**Status:** v1 | **Last updated:** 2026-02-17

---

## 1. Overview

Infrastructure configuration for dev, test, and production environments. All environments use the same backing services (PostgreSQL, NATS, MinIO) — only configuration differs.

## 2. Service Ports

### 2.1 Application Services (Dev)

| Service | Port | Protocol |
|---------|------|----------|
| Auth Gateway | 3000 | HTTP |
| API | 3001 | HTTP |
| Web SM | 3002 | HTTP |
| TUI SM | 3003 | HTTP |

### 2.2 Infrastructure Services (Dev)

| Service | Port(s) | Protocol |
|---------|---------|----------|
| PostgreSQL | 5432 | TCP |
| NATS | 4222 (client), 8222 (monitoring) | TCP, HTTP |
| MinIO | 9000 (API), 9001 (console) | HTTP |

### 2.3 Production

| Service | Port | Exposed |
|---------|------|---------|
| nginx | 80, 443 | External |
| Auth Gateway | 3000 | Internal only |
| API | 3001 | Internal only |
| Web SM | 3002 | Internal only |
| TUI SM | 3003 | Internal only |
| PostgreSQL | 5432 | Internal only |
| NATS | 4222 | Internal only |
| MinIO | 9000 | Internal only |

---

## 3. Docker Compose — Development

**File:** `docker-compose.dev.yml`

Runs infrastructure services only. Clojure services run locally via REPL.

```yaml
# docker-compose.dev.yml
services:
  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: ajet_chat
      POSTGRES_USER: ajet
      POSTGRES_PASSWORD: ajet_dev
    volumes:
      - pgdata_dev:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
      interval: 5s
      timeout: 3s
      retries: 5

  nats:
    image: nats:2.10-alpine
    ports:
      - "4222:4222"
      - "8222:8222"
    command: ["--js", "--sd", "/data", "-m", "8222"]
    volumes:
      - natsdata_dev:/data
    healthcheck:
      test: ["CMD", "nats-server", "--signal", "ldm"]
      interval: 5s
      timeout: 3s
      retries: 5

  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - miniodata_dev:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 3s
      retries: 5

  minio-init:
    image: minio/mc:latest
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 minioadmin minioadmin;
      mc mb --ignore-existing local/ajet-chat;
      "

volumes:
  pgdata_dev:
  natsdata_dev:
  miniodata_dev:
```

**Usage:**
```bash
docker compose -f docker-compose.dev.yml up -d
# Then start Clojure services via REPL
clj -A:dev:api:web-sm:tui-sm:auth-gw
```

---

## 4. Docker Compose — Test

**File:** `docker-compose.test.yml`

Fresh database per run. Separate ports to avoid conflicts with dev.

```yaml
# docker-compose.test.yml
services:
  postgres-test:
    image: postgres:16-alpine
    ports:
      - "5433:5432"
    environment:
      POSTGRES_DB: ajet_chat_test
      POSTGRES_USER: ajet
      POSTGRES_PASSWORD: ajet_test
    tmpfs:
      - /var/lib/postgresql/data    # ephemeral — fresh DB each run
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat_test"]
      interval: 3s
      timeout: 2s
      retries: 10

  nats-test:
    image: nats:2.10-alpine
    ports:
      - "4223:4222"
    command: ["--js"]               # JetStream enabled, no persistent storage
    healthcheck:
      test: ["CMD", "nats-server", "--signal", "ldm"]
      interval: 3s
      timeout: 2s
      retries: 10

  minio-test:
    image: minio/minio:latest
    ports:
      - "9002:9000"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data
    tmpfs:
      - /data                        # ephemeral — no persistent files
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 3s
      timeout: 2s
      retries: 10

  minio-test-init:
    image: minio/mc:latest
    depends_on:
      minio-test:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio-test:9000 minioadmin minioadmin;
      mc mb --ignore-existing local/ajet-chat;
      "

  # E2E profile — adds application service containers
  auth-gw:
    profiles: ["e2e"]
    build:
      context: .
      dockerfile: auth-gw/Dockerfile
    ports:
      - "3100:3000"
    environment:
      AJET_DB_HOST: postgres-test
      AJET_DB_PORT: 5432
      AJET_DB_DBNAME: ajet_chat_test
      AJET_DB_USER: ajet
      AJET_DB_PASSWORD: ajet_test
      AJET_SERVICES_API_HOST: api
      AJET_SERVICES_WEB_SM_HOST: web-sm
      AJET_SERVICES_TUI_SM_HOST: tui-sm
    depends_on:
      postgres-test:
        condition: service_healthy

  api:
    profiles: ["e2e"]
    build:
      context: .
      dockerfile: api/Dockerfile
    environment:
      AJET_DB_HOST: postgres-test
      AJET_DB_PORT: 5432
      AJET_DB_DBNAME: ajet_chat_test
      AJET_DB_USER: ajet
      AJET_DB_PASSWORD: ajet_test
      AJET_NATS_URL: nats://nats-test:4222
      AJET_MINIO_ENDPOINT: http://minio-test:9000
    depends_on:
      postgres-test:
        condition: service_healthy
      nats-test:
        condition: service_healthy
      minio-test-init:
        condition: service_completed_successfully

  web-sm:
    profiles: ["e2e"]
    build:
      context: .
      dockerfile: web-sm/Dockerfile
    environment:
      AJET_API_BASE_URL: http://api:3001
      AJET_NATS_URL: nats://nats-test:4222
    depends_on:
      nats-test:
        condition: service_healthy

  tui-sm:
    profiles: ["e2e"]
    build:
      context: .
      dockerfile: tui-sm/Dockerfile
    environment:
      AJET_API_BASE_URL: http://api:3001
      AJET_NATS_URL: nats://nats-test:4222
    depends_on:
      nats-test:
        condition: service_healthy
```

**Usage:**
```bash
# Unit + integration tests
docker compose -f docker-compose.test.yml up -d
clj -M:test/unit
clj -M:test/integration

# E2E tests (full stack)
docker compose -f docker-compose.test.yml --profile e2e up -d --build
clj -M:test/e2e

# Teardown
docker compose -f docker-compose.test.yml --profile e2e down -v
```

---

## 5. Docker Compose — Production

**File:** `docker-compose.yml`

Full stack with nginx TLS termination.

```yaml
# docker-compose.yml
services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - auth-gw
    restart: unless-stopped

  auth-gw:
    build:
      context: .
      dockerfile: auth-gw/Dockerfile
    environment:
      AJET_DB_HOST: postgres
      AJET_DB_PASSWORD: ${AJET_DB_PASSWORD}
      AJET_OAUTH_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
      AJET_OAUTH_GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
      AJET_SERVICES_API_HOST: api
      AJET_SERVICES_WEB_SM_HOST: web-sm
      AJET_SERVICES_TUI_SM_HOST: tui-sm
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  api:
    build:
      context: .
      dockerfile: api/Dockerfile
    environment:
      AJET_DB_HOST: postgres
      AJET_DB_PASSWORD: ${AJET_DB_PASSWORD}
      AJET_NATS_URL: nats://nats:4222
      AJET_MINIO_ENDPOINT: http://minio:9000
      AJET_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
      AJET_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
    depends_on:
      postgres:
        condition: service_healthy
      nats:
        condition: service_healthy
      minio:
        condition: service_healthy
    restart: unless-stopped

  web-sm:
    build:
      context: .
      dockerfile: web-sm/Dockerfile
    environment:
      AJET_API_BASE_URL: http://api:3001
      AJET_NATS_URL: nats://nats:4222
    depends_on:
      nats:
        condition: service_healthy
    restart: unless-stopped

  tui-sm:
    build:
      context: .
      dockerfile: tui-sm/Dockerfile
    environment:
      AJET_API_BASE_URL: http://api:3001
      AJET_NATS_URL: nats://nats:4222
    depends_on:
      nats:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ajet_chat
      POSTGRES_USER: ajet
      POSTGRES_PASSWORD: ${AJET_DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  nats:
    image: nats:2.10-alpine
    command: ["--js", "--sd", "/data", "-m", "8222"]
    volumes:
      - natsdata:/data
    healthcheck:
      test: ["CMD", "nats-server", "--signal", "ldm"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  minio:
    image: minio/minio:latest
    environment:
      MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
      MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
    command: server /data
    volumes:
      - miniodata:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:
  natsdata:
  miniodata:
```

**Environment file (`.env`):**
```
AJET_DB_PASSWORD=<strong-random-password>
GITHUB_CLIENT_ID=<from-github-oauth-app>          # seed only — migrated to DB on first start
GITHUB_CLIENT_SECRET=<from-github-oauth-app>       # seed only — migrated to DB on first start
MINIO_ACCESS_KEY=<random-access-key>
MINIO_SECRET_KEY=<random-secret-key>
```

**Note:** OAuth credentials in `.env` are auto-migrated to the `oauth_providers` DB table on first startup. After that, manage OAuth providers via the admin setup wizard or the admin API. You can also skip these env vars entirely and configure providers through the setup wizard on first deployment.

---

## 6. nginx Configuration (Production)

**File:** `nginx/nginx.conf`

```nginx
worker_processes auto;

events {
    worker_connections 4096;
}

http {
    upstream auth_gw {
        server auth-gw:3000;
    }

    # Redirect HTTP → HTTPS
    server {
        listen 80;
        server_name chat.example.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name chat.example.com;

        ssl_certificate     /etc/nginx/certs/fullchain.pem;
        ssl_certificate_key /etc/nginx/certs/privkey.pem;
        ssl_protocols       TLSv1.2 TLSv1.3;

        # Proxy all traffic to Auth Gateway
        location / {
            proxy_pass http://auth_gw;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            # SSE support — disable buffering
            proxy_buffering off;
            proxy_cache off;
            proxy_read_timeout 86400s;   # 24h for SSE connections
            proxy_send_timeout 86400s;

            # WebSocket support (future, for voice)
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }

        # Static file size limit
        client_max_body_size 10m;
    }
}
```

---

## 7. NATS JetStream Configuration

### 7.1 Stream Setup

Created automatically by the API service on startup:

```clojure
;; Stream: ajet-events
{:name        "ajet-events"
 :subjects    ["chat.events.>"
               "chat.dm.>"
               "chat.typing.>"
               "chat.presence.>"
               "chat.notifications.>"
               "chat.audit"]
 :retention   :limits           ;; retain by limits (not interest or work-queue)
 :max-age     86400000000000    ;; 24 hours in nanoseconds
 :max-bytes   1073741824        ;; 1 GB
 :storage     :file
 :replicas    1                 ;; single node for v1
 :discard     :old}             ;; discard oldest when limits hit
```

### 7.2 Consumer Setup

Each session manager creates a durable consumer on connect:

```clojure
;; Consumer: per-user, created by SM on SSE connection
{:durable-name  "sm-{service}-{user-id}"   ;; e.g. "sm-web-abc123"
 :filter-subjects ["chat.events.{community-id}"
                   "chat.dm.{channel-id-1}"
                   "chat.dm.{channel-id-2}"
                   "chat.notifications.{user-id}"]
 :deliver-policy :by-start-time             ;; on reconnect: from last-event-id time
 :ack-policy     :none}                     ;; no ack needed for real-time delivery
```

### 7.3 Subject ACLs (Future)

Not needed for v1 (single NATS instance, trusted services). For multi-tenant production:
- API: publish to all subjects
- SMs: subscribe only to subjects relevant to their connected users
- No direct client access to NATS (all mediated by SMs)

---

## 8. MinIO Configuration

### 8.1 Bucket Setup

Single bucket `ajet-chat` with:
- Default retention: none (files kept indefinitely)
- Versioning: disabled (no need for file history in v1)

### 8.2 Storage Key Convention

```
attachments/{message-uuid}/{filename}       — message attachments
avatars/users/{user-uuid}/{filename}         — user avatars
avatars/communities/{community-uuid}/{filename} — community icons
avatars/webhooks/{webhook-uuid}/{filename}   — webhook bot icons
```

### 8.3 Access Patterns

- **Upload:** API service writes to MinIO on file upload
- **Download:** Auth GW proxies `/files/*` requests to MinIO (or API generates presigned URLs)
- **No direct client access** to MinIO in v1 — all through API/Auth GW

---

## 9. PostgreSQL Configuration

### 9.1 Recommended Settings (Production)

```
shared_buffers = 256MB          # 25% of available RAM
work_mem = 16MB
effective_cache_size = 768MB    # 75% of available RAM
maintenance_work_mem = 128MB
max_connections = 100
log_min_duration_statement = 500   # log queries > 500ms
```

### 9.2 Backups

- **pg_dump** daily cron job (simple, sufficient for v1)
- Dump to local volume, rotate 7 days
- Future: WAL archiving for point-in-time recovery

---

## 10. Dockerfile Template

Each service uses the same multi-stage build:

```dockerfile
# {service}/Dockerfile
FROM clojure:temurin-21-tools-deps-1.12 AS builder
WORKDIR /app
COPY deps.edn .
COPY shared/ shared/
COPY {service}/ {service}/
RUN clj -T:build uber :module {service}

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/{service}.jar app.jar
EXPOSE {port}
CMD ["java", "-jar", "app.jar"]
```

**Build alias** (root `deps.edn`):
```clojure
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"}}
        :ns-default build}
```

---

## 11. Environment Variables Reference

### 11.1 All Services

| Variable | Default | Description |
|----------|---------|-------------|
| `AJET_DB_HOST` | `localhost` | PostgreSQL host |
| `AJET_DB_PORT` | `5432` | PostgreSQL port |
| `AJET_DB_DBNAME` | `ajet_chat` | Database name |
| `AJET_DB_USER` | `ajet` | Database user |
| `AJET_DB_PASSWORD` | — | Database password (required) |
| `AJET_NATS_URL` | `nats://localhost:4222` | NATS server URL |
| `AJET_MINIO_ENDPOINT` | `http://localhost:9000` | MinIO endpoint |
| `AJET_MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key |
| `AJET_MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key |

### 11.2 Auth Gateway Only

| Variable | Default | Description |
|----------|---------|-------------|
| `AJET_OAUTH_GITHUB_CLIENT_ID` | — | GitHub OAuth app client ID (seed only — auto-migrated to DB) |
| `AJET_OAUTH_GITHUB_CLIENT_SECRET` | — | GitHub OAuth app client secret (seed only — auto-migrated to DB) |
| `AJET_OAUTH_GITEA_CLIENT_ID` | — | Gitea OAuth client ID (seed only — auto-migrated to DB) |
| `AJET_OAUTH_GITEA_CLIENT_SECRET` | — | Gitea OAuth client secret (seed only — auto-migrated to DB) |
| `AJET_OAUTH_GITEA_BASE_URL` | — | Gitea instance URL (seed only — auto-migrated to DB) |
| `AJET_OAUTH_OIDC_CLIENT_ID` | — | OIDC client ID (seed only — auto-migrated to DB) |
| `AJET_OAUTH_OIDC_CLIENT_SECRET` | — | OIDC client secret (seed only — auto-migrated to DB) |
| `AJET_OAUTH_OIDC_ISSUER_URL` | — | OIDC issuer URL (seed only — auto-migrated to DB) |
| `AJET_SERVICES_API_HOST` | `localhost` | API service host |
| `AJET_SERVICES_API_PORT` | `3001` | API service port |
| `AJET_SERVICES_WEB_SM_HOST` | `localhost` | Web SM host |
| `AJET_SERVICES_WEB_SM_PORT` | `3002` | Web SM port |
| `AJET_SERVICES_TUI_SM_HOST` | `localhost` | TUI SM host |
| `AJET_SERVICES_TUI_SM_PORT` | `3003` | TUI SM port |

**Note on OAuth env vars:** The `AJET_OAUTH_*` environment variables serve as a seed for initial deployment only. On first startup, if the `oauth_providers` DB table is empty, Auth GW auto-migrates these values into the table. After migration, providers are managed exclusively via the admin API (`/api/admin/oauth-providers`) or the setup wizard. The env vars are ignored once providers exist in the DB.

### 11.3 Session Managers Only

| Variable | Default | Description |
|----------|---------|-------------|
| `AJET_API_BASE_URL` | `http://localhost:3001` | Internal API base URL |

---

## 12. Test Cases

| ID | Test | Type | Description |
|----|------|------|-------------|
| INFRA-T1 | Dev compose starts | Integration | `docker compose -f docker-compose.dev.yml up` starts PG + NATS + MinIO |
| INFRA-T2 | PG accessible | Integration | Can connect to Postgres on port 5432 |
| INFRA-T3 | NATS accessible | Integration | Can connect to NATS on port 4222 |
| INFRA-T4 | MinIO accessible | Integration | Can connect to MinIO on port 9000 |
| INFRA-T5 | MinIO bucket created | Integration | `ajet-chat` bucket exists after `minio-init` |
| INFRA-T6 | Test compose fresh DB | Integration | Test DB has no data from previous runs |
| INFRA-T7 | JetStream enabled | Integration | NATS JetStream API responds to stream info request |
| INFRA-T8 | Health checks pass | Integration | All service health checks return healthy |
| INFRA-T9 | Prod compose full stack | E2E | All services start and connect to each other |
| INFRA-T10 | nginx proxies to auth-gw | E2E | HTTPS request reaches Auth Gateway via nginx |
