---
sidebar_position: 2
title: Standalone Local Setup
---

# Standalone Local Setup

**The goal**: a clueless user clones the repo, runs one script, and has a working Reflector instance locally. No cloud accounts, no API keys, no manual env file editing.

```bash
git clone https://github.com/monadical-sas/reflector.git
cd reflector
./scripts/setup-standalone.sh
```

On Ubuntu, the setup script installs Docker automatically if missing.

The script is idempotent — safe to re-run at any time. It detects what's already set up and skips completed steps.

## Prerequisites

- Docker with Compose V2 plugin (Docker Desktop, OrbStack, or Docker Engine + compose plugin)
- Mac (Apple Silicon) or Linux
- 16GB+ RAM (32GB recommended for 14B LLM models)
- **Mac only**: [Ollama](https://ollama.com/download) installed (`brew install ollama`)

### Installing Docker (if not already installed)

**Ubuntu**: The setup script runs `install-docker-ubuntu.sh` automatically when Docker is missing. Or run it manually:

```bash
./scripts/install-docker-ubuntu.sh
```

**Mac**: Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [OrbStack](https://orbstack.dev/).

## What the script does

### 1. LLM inference via Ollama

**Mac**: starts Ollama natively (Metal GPU acceleration). Pulls the LLM model. Docker containers reach it via `host.docker.internal:11435`.

**Linux**: starts containerized Ollama via `docker-compose.standalone.yml` profile (`ollama-gpu` with NVIDIA, `ollama-cpu` without). Pulls model inside the container.

### 2. Environment files

Generates `server/.env` and `www/.env.local` with standalone defaults:

**`server/.env`** — key settings:

| Variable              | Value                                              | Why                                 |
| --------------------- | -------------------------------------------------- | ----------------------------------- |
| `DATABASE_URL`        | `postgresql+asyncpg://...@postgres:5432/reflector` | Docker-internal hostname            |
| `REDIS_HOST`          | `redis`                                            | Docker-internal hostname            |
| `CELERY_BROKER_URL`   | `redis://redis:6379/1`                             | Docker-internal hostname            |
| `AUTH_BACKEND`        | `none`                                             | No Authentik in standalone          |
| `TRANSCRIPT_BACKEND`  | `modal`                                            | HTTP API to self-hosted CPU service |
| `TRANSCRIPT_URL`      | `http://cpu:8000`                                  | Docker-internal CPU service         |
| `DIARIZATION_BACKEND` | `modal`                                            | HTTP API to self-hosted CPU service |
| `DIARIZATION_URL`     | `http://cpu:8000`                                  | Docker-internal CPU service         |
| `TRANSLATION_BACKEND` | `passthrough`                                      | No Modal                            |
| `LLM_URL`             | `http://host.docker.internal:11435/v1` (Mac)       | Ollama endpoint                     |

**`www/.env.local`** — key settings:

| Variable                | Value                                      |
| ----------------------- | ------------------------------------------ |
| `API_URL`               | `https://localhost:3043` or `https://YOUR_IP:3043` (Linux) |
| `SERVER_API_URL`        | `http://server:1250`                       |
| `WEBSOCKET_URL`         | `auto`                                    |
| `FEATURE_REQUIRE_LOGIN` | `false`                                    |
| `NEXTAUTH_SECRET`       | `standalone-dev-secret-not-for-production` |

If env files already exist (including symlinks from worktree setup), the script resolves symlinks and ensures all standalone-critical vars are set. Existing vars not related to standalone are preserved.

### 3. Object storage (Garage)

Standalone uses [Garage](https://garagehq.deuxfleurs.fr/) — a lightweight S3-compatible object store running in Docker. The setup script starts Garage, initializes the layout, creates a bucket and access key, and writes the credentials to `server/.env`.

**`server/.env`** — storage settings added by the script:

| Variable                                   | Value                | Why                                   |
| ------------------------------------------ | -------------------- | ------------------------------------- |
| `TRANSCRIPT_STORAGE_BACKEND`               | `aws`                | Uses the S3-compatible storage driver |
| `TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL`      | `http://garage:3900` | Docker-internal Garage S3 API         |
| `TRANSCRIPT_STORAGE_AWS_BUCKET_NAME`       | `reflector-media`    | Created by the script                 |
| `TRANSCRIPT_STORAGE_AWS_REGION`            | `garage`             | Must match Garage config              |
| `TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID`     | _(auto-generated)_   | Created by `garage key create`        |
| `TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY` | _(auto-generated)_   | Created by `garage key create`        |

The `TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL` setting enables S3-compatible backends. When set, the storage driver uses path-style addressing and routes all requests to the custom endpoint. When unset (production AWS), behavior is unchanged.

Garage config template lives at `scripts/garage.toml`. The setup script generates `data/garage.toml` (gitignored) with a random RPC secret and mounts it read-only into the container. Single-node, `replication_factor=1`.

> **Note**: Presigned URLs embed the Garage Docker hostname (`http://garage:3900`). This is fine — the server proxies S3 responses to the browser. Modal GPU workers cannot reach internal Garage, but standalone doesn't use Modal.

### 4. Transcription and diarization

Standalone runs the self-hosted ML service (`gpu/self_hosted/`) in a CPU-only Docker container named `cpu`. This is the same FastAPI service used for Modal.com GPU deployments, but built with `Dockerfile.cpu` (no NVIDIA CUDA dependencies). The compose service is named `cpu` (not `gpu`) to make clear it runs without GPU acceleration; the source code lives in `gpu/self_hosted/` because it's shared with the GPU deployment.

The `modal` backend name is reused — it just means "HTTP API client". Setting `TRANSCRIPT_URL` / `DIARIZATION_URL` to `http://cpu:8000` routes requests to the local container instead of Modal.com.

On first start, the service downloads pyannote speaker diarization models (~1GB) from a public S3 bundle. Models are cached in a Docker volume (`gpu_cache`) so subsequent starts are fast. No HuggingFace token or API key needed.

> **Performance**: CPU-only transcription and diarization work but are slow (~15 min for a 3 min file). For faster processing on Linux with NVIDIA GPU, use `--profile gpu-nvidia` instead (see `docker-compose.standalone.yml`).

### 5. Docker services

```bash
docker compose up -d postgres redis garage cpu server worker beat web
```

All services start in a single command. Garage and `cpu` are already started by earlier steps but included for idempotency. No Hatchet in standalone mode — LLM processing (summaries, topics, titles) runs via Celery tasks.

### 6. Database migrations

Run automatically by the `server` container on startup (`runserver.sh` calls `alembic upgrade head`). No manual step needed.

### 7. Health check

Verifies:

- CPU service responds (transcription + diarization ready)
- Server responds at `http://localhost:1250/health`
- Frontend serves at `http://localhost:3000` (or via Caddy at `https://localhost:3043`)
- LLM endpoint reachable from inside containers

## Services

| Service    | Port       | Purpose                                            |
| ---------- | ---------- | -------------------------------------------------- |
| `caddy`    | 3043       | Reverse proxy (HTTPS, self-signed cert)            |
| `server`   | 1250       | FastAPI backend (runs migrations on start)         |
| `web`      | 3000       | Next.js frontend                                   |
| `postgres` | 5432       | PostgreSQL database                                |
| `redis`    | 6379       | Cache + Celery broker                              |
| `garage`   | 3900, 3903 | S3-compatible object storage (S3 API + admin API)  |
| `cpu`      | —          | Self-hosted transcription + diarization (CPU-only) |
| `worker`   | —          | Celery worker (live pipeline post-processing)      |
| `beat`     | —          | Celery beat (scheduled tasks)                      |

## Testing programmatically

After the setup script completes, verify the full pipeline (upload, transcription, diarization, LLM summary) via the API:

```bash
# 1. Create a transcript
TRANSCRIPT_ID=$(curl -s -X POST 'http://localhost:1250/v1/transcripts' \
  -H 'Content-Type: application/json' \
  -d '{"name":"test-upload"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created: $TRANSCRIPT_ID"

# 2. Upload an audio file (single-chunk upload)
curl -s "http://localhost:1250/v1/transcripts/${TRANSCRIPT_ID}/record/upload?chunk_number=0&total_chunks=1" \
  -X POST -F "chunk=@/path/to/audio.mp3"

# 3. Poll until processing completes (status: ended or error)
while true; do
  STATUS=$(curl -s "http://localhost:1250/v1/transcripts/${TRANSCRIPT_ID}" \
    | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
  echo "Status: $STATUS"
  case "$STATUS" in ended|error) break;; esac
  sleep 10
done

# 4. Check the result
curl -s "http://localhost:1250/v1/transcripts/${TRANSCRIPT_ID}" | python3 -m json.tool
```

Expected result: status `ended`, auto-generated `title`, `short_summary`, `long_summary`, and `transcript` text with `Speaker 0` / `Speaker 1` labels.

CPU-only processing is slow (~15 min for a 3 min audio file). Diarization finishes in ~3 min, transcription takes the rest.

## Enabling HTTPS (droplet via IP)

To serve Reflector over HTTPS on a droplet accessed by IP (self-signed certificate):

1. **Copy the Caddyfile** (no edits needed — `:443` catches all HTTPS inside container, mapped to host port 3043):
   ```bash
   cp Caddyfile.standalone.example Caddyfile
   ```

2. **Update `www/.env.local`** with HTTPS URLs (port 3043):
   ```env
   API_URL=https://YOUR_IP:3043
   WEBSOCKET_URL=wss://YOUR_IP:3043
   SITE_URL=https://YOUR_IP:3043
   NEXTAUTH_URL=https://YOUR_IP:3043
   ```

3. **Restart services**:
   ```bash
   docker compose -f docker-compose.standalone.yml --profile ollama-cpu up -d
   ```
   (Use `ollama-gpu` instead of `ollama-cpu` if you have an NVIDIA GPU.)

4. **Access** at `https://YOUR_IP:3043`. The browser will warn about the self-signed cert — click **Advanced** → **Proceed to YOUR_IP (unsafe)**. All traffic (page, API, WebSocket) uses the same origin, so accepting once is enough.

## Troubleshooting

### ERR_SSL_PROTOCOL_ERROR when accessing https://YOUR_IP

You do **not** need a domain — the setup works with an IP address. This error usually means Caddy isn't serving TLS on port 3043. Check in order:

1. **Caddyfile** — must use the `:443` catch-all (container-internal; Docker maps host 3043 → container 443):
   ```bash
   cp Caddyfile.standalone.example Caddyfile
   ```

2. **Firewall** — allow port 3043 (common on DigitalOcean):
   ```bash
   sudo ufw allow 3043
   sudo ufw status
   ```

3. **Caddy running** — verify and restart:
   ```bash
   docker compose -f docker-compose.standalone.yml ps
   docker compose -f docker-compose.standalone.yml logs caddy --tail 20
   docker compose -f docker-compose.standalone.yml --profile ollama-cpu up -d
   ```

4. **Test from the droplet** — if this works, the issue is external (firewall, network):
   ```bash
   curl -vk https://localhost:3043
   ```

5. **localhost works but external IP fails** — Re-run the setup script; it generates a Caddyfile with your droplet IP explicitly, so Caddy provisions the cert at startup:
   ```bash
   ./scripts/setup-standalone.sh
   ```
   Or manually create `Caddyfile` with your IP (replace 138.197.162.116):
   ```
   https://138.197.162.116, localhost {
       tls internal
       handle /v1/* { reverse_proxy server:1250 }
       handle /health { reverse_proxy server:1250 }
       handle { reverse_proxy web:3000 }
   }
   ```
   Then restart: `docker compose -f docker-compose.standalone.yml --profile ollama-cpu up -d`

6. **Still failing?** Try HTTP (no TLS) — create `Caddyfile`:
   ```
   :80 {
       handle /v1/* { reverse_proxy server:1250 }
       handle /health { reverse_proxy server:1250 }
       handle { reverse_proxy web:3000 }
   }
   ```
   Update `www/.env.local`: `API_URL=http://YOUR_IP:3043`, `WEBSOCKET_URL=ws://YOUR_IP:3043`, `SITE_URL=http://YOUR_IP:3043`, `NEXTAUTH_URL=http://YOUR_IP:3043`. Restart, then access `http://YOUR_IP:3043`.

### Docker not ready

If setup fails with "Docker not ready", on Ubuntu run `./scripts/install-docker-ubuntu.sh`. If Docker is installed but you're not root, run `newgrp docker` then run the setup script again.

### Port conflicts (most common issue)

If the frontend or backend behaves unexpectedly (e.g., env vars seem ignored, changes don't take effect), **check for port conflicts first**:

```bash
# Check what's listening on key ports
lsof -i :3000   # frontend
lsof -i :1250   # backend
lsof -i :5432   # postgres
lsof -i :3900   # Garage S3 API
lsof -i :6379   # Redis

# Kill stale processes on a port
lsof -ti :3000 | xargs kill
```

Common causes:

- A stale `next dev` or `pnpm dev` process from another terminal/worktree
- Another Docker Compose project (different worktree) with containers on the same ports — the setup script only manages its own project; containers from other projects must be stopped manually (`docker ps` to find them, `docker stop` to kill them)

The setup script checks ports 3000, 1250, 5432, 6379, 3900, 3903 for conflicts before starting services. It ignores OrbStack/Docker Desktop port forwarding processes (which always bind these ports but are not real conflicts).

### OrbStack false port-conflict warnings (Mac)

If you use OrbStack as your Docker runtime, `lsof` will show OrbStack binding ports like 3000, 1250, etc. even when no containers are running. This is OrbStack's port forwarding mechanism — not a real conflict. The setup script filters these out automatically.

### Re-enabling authentication

Standalone runs without authentication (`FEATURE_REQUIRE_LOGIN=false`, `AUTH_BACKEND=none`). To re-enable:

1. In `www/.env.local`: set `FEATURE_REQUIRE_LOGIN=true`, uncomment `AUTHENTIK_ISSUER` and `AUTHENTIK_REFRESH_TOKEN_URL`
2. In `server/.env`: set `AUTH_BACKEND=authentik` (or your backend), configure `AUTH_JWT_AUDIENCE`
3. Restart: `docker compose -f docker-compose.standalone.yml up -d --force-recreate web server`

## What's NOT covered

These require external accounts and infrastructure that can't be scripted:

- **Live meeting rooms** — requires Daily.co account, S3 bucket, IAM roles
- **Authentication** — requires Authentik deployment and OAuth configuration
- **Hatchet workflows** — requires separate Hatchet setup for multitrack processing
- **Production deployment** — see [Deployment Guide](./overview)

## Current status

All steps implemented. The setup script handles everything end-to-end:

- Step 1 (Ollama/LLM) — implemented
- Step 2 (environment files) — implemented
- Step 3 (object storage / Garage) — implemented
- Step 4 (transcription/diarization) — implemented (self-hosted GPU service)
- Steps 5-7 (Docker, migrations, health) — implemented
- **Unified script**: `scripts/setup-standalone.sh`
