Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
# Docker self-host binds published ports to loopback by default. This is an
# intentional safety default while secrets, signup policy, email, TLS, origins,
# and trusted proxies are still being configured.
#
# Leave BIND_HOST empty/127.0.0.1 for localhost-only installs.
# For a trusted private LAN, set a specific host IP such as 192.168.1.10 after
# rotating example secrets and setting FRONTEND_ORIGIN/CORS_ALLOWED_ORIGINS.
# Do not set BIND_HOST=0.0.0.0 for normal use; use a reverse proxy or tunnel
# for public/cross-machine access. The preflight refuses all-interface binds
# unless MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1 is set after security review.
BIND_HOST=127.0.0.1
# MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
Expand Down Expand Up @@ -58,7 +70,7 @@ MULTICA_PUBLIC_URL=
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
# announced ranges for cloud deployments. Never use 0.0.0.0/0 or ::/0.
MULTICA_TRUSTED_PROXIES=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
Expand Down Expand Up @@ -186,6 +198,7 @@ CORS_ALLOWED_ORIGINS=
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# Never use 0.0.0.0/0 or ::/0.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
Expand Down Expand Up @@ -250,6 +263,9 @@ ALLOW_SIGNUP=true
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
# only requires restarting the backend / compose stack — not rebuilding web.
# It is not hot-reloaded.
#
# Before exposing a self-host instance beyond localhost, bootstrap the intended
# users/workspace, then set ALLOW_SIGNUP=false and DISABLE_WORKSPACE_CREATION=true.

# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
Expand Down
30 changes: 20 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
fi; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@bash scripts/selfhost-preflight.sh .env
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
Expand All @@ -81,17 +82,21 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@health_url=$$(bash scripts/selfhost-url.sh .env health); \
for i in $$(seq 1 30); do \
if curl -sf "$$health_url" > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@health_url=$$(bash scripts/selfhost-url.sh .env health); \
if curl -sf "$$health_url" > /dev/null 2>&1; then \
frontend_url=$$(bash scripts/selfhost-url.sh .env frontend); \
backend_url=$$(bash scripts/selfhost-url.sh .env backend); \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo " Frontend: $$frontend_url"; \
echo " Backend: $$backend_url"; \
echo ""; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
Expand Down Expand Up @@ -125,20 +130,25 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
fi; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@bash scripts/selfhost-preflight.sh .env
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@health_url=$$(bash scripts/selfhost-url.sh .env health); \
for i in $$(seq 1 30); do \
if curl -sf "$$health_url" > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@health_url=$$(bash scripts/selfhost-url.sh .env health); \
if curl -sf "$$health_url" > /dev/null 2>&1; then \
frontend_url=$$(bash scripts/selfhost-url.sh .env frontend); \
backend_url=$$(bash scripts/selfhost-url.sh .env backend); \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo " Frontend: $$frontend_url"; \
echo " Backend: $$backend_url"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or read the generated code from backend logs when Resend is unset."; \
Expand Down
43 changes: 41 additions & 2 deletions SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,24 @@ Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080

`docker-compose.selfhost.yml` binds published ports to `127.0.0.1` by default. That localhost-only default is intentional: first-run self-hosts may still have open signup/workspace creation, no email provider, no TLS, and example secrets if you bypass `make selfhost`. For private LAN access, set `BIND_HOST` to one specific LAN IP after hardening the instance. For public or cross-machine access, keep Docker bound to loopback and use a TLS reverse proxy or tunnel.

> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.

### Step 2 — Log In

Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:

- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:` locally). Useful for one-off testing on a single machine. Do not share verification-code log lines.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.

Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation.

> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.

Before exposing the instance beyond localhost, rotate `JWT_SECRET` and `POSTGRES_PASSWORD`, keep `APP_ENV=production`, leave `MULTICA_DEV_VERIFICATION_CODE` empty, configure Resend or SMTP, set exact origins, and lock down signup/workspace creation after bootstrap. The self-host preflight runs automatically in `make selfhost`, `make selfhost-build`, and the Unix installer.

### Step 3 — Install CLI & Start Daemon

The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
Expand Down Expand Up @@ -250,7 +254,7 @@ The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.

Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).

- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:` locally). Useful for one-off testing. Do not share verification-code log lines.

```bash
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
Expand Down Expand Up @@ -447,13 +451,48 @@ Edit `.env` — at minimum, change `JWT_SECRET`:
JWT_SECRET=$(openssl rand -hex 32)
```

Also change `POSTGRES_PASSWORD` and keep `DATABASE_URL` in sync if you are not using the Makefile, which does this for you.

Before running Compose, validate the env file without printing secrets:

```bash
bash scripts/selfhost-preflight.sh .env
```

Then start everything:

```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

### Exposure modes

**Localhost (default):** leave `BIND_HOST=127.0.0.1`; `multica setup self-host` points the CLI at `localhost`.

**Trusted private LAN:** set `BIND_HOST` to one specific host IP, not `0.0.0.0`, and set exact browser origins:

```bash
BIND_HOST=192.168.1.50
FRONTEND_ORIGIN=http://192.168.1.50:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000
ALLOW_SIGNUP=false
DISABLE_WORKSPACE_CREATION=true
```

**Public or cross-machine:** keep `BIND_HOST=127.0.0.1` and put Caddy, Nginx, or Cloudflare Tunnel in front. Terminate TLS, forward `/ws` with the WebSocket `Upgrade` header, set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to the exact public origin, and set `MULTICA_TRUSTED_PROXIES` plus `RATE_LIMIT_TRUSTED_PROXIES` to the exact proxy/CDN CIDRs. Never use `0.0.0.0/0` or `::/0` as trusted proxy ranges.

`BIND_HOST=0.0.0.0` publishes raw Docker ports on every interface and is refused unless `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1` is set. Treat that override as a reviewed infrastructure exception, not normal public setup.

### Docker volumes are sensitive

Compose creates two persistent named volumes:

- `pgdata` stores the PostgreSQL database: users, workspaces, issues, comments, tokens, OAuth state, and other application records.
- `backend_uploads` stores local attachment/upload files when S3 is not configured.

Back them up and restore them together for point-in-time recovery. Restrict host and Docker daemon access to the machine, and do not share raw volume archives, `pg_dump` output, or upload tarballs publicly. If support needs diagnostics, share schema/version/error snippets with secrets and user content redacted instead of database dumps.

## Manual CLI Configuration

If you prefer configuring the CLI step by step instead of `multica setup`:
Expand Down
51 changes: 49 additions & 2 deletions SELF_HOSTING_ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |

### Exposure Modes and Preflight Guard

Docker Compose publishes backend and frontend ports on `127.0.0.1` by default. This is the safe first-run shape: secrets, signup/workspace policy, email, TLS, origins, and trusted proxy CIDRs can be configured before anything is reachable from another machine.

| Mode | Settings | Notes |
|------|----------|-------|
| Localhost | `BIND_HOST=127.0.0.1` | Default. Use `multica setup self-host` on the same machine. |
| Trusted private LAN | `BIND_HOST=<specific LAN IP>` plus exact `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` | Private network only. Rotate secrets, configure email, keep `APP_ENV=production`, clear fixed dev codes, and lock down signup/workspace creation first. |
| Public / cross-machine | Keep `BIND_HOST=127.0.0.1`; use Caddy, Nginx, or Cloudflare Tunnel | Recommended public path. Terminate TLS, forward `/ws` Upgrade, set exact origins, and use narrow trusted proxy CIDRs. |

The self-host preflight runs before Docker starts in `make selfhost`, `make selfhost-build`, and the Unix installer. You can also run it manually:

```bash
bash scripts/selfhost-preflight.sh .env
```

`BIND_HOST=0.0.0.0` publishes raw Docker ports on every interface and is refused unless `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1` is set. Use that override only after security review; it does not skip the secret/origin/email/signup checks.

### Database Pool Tuning (Optional)

These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
Expand Down Expand Up @@ -100,6 +118,15 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

### Docker Volume Security

The default Compose stack creates two persistent named volumes:

- `pgdata` (`/var/lib/postgresql/data`) contains the full PostgreSQL data directory, including account records, workspace data, task/comment history, tokens, and integration state.
- `backend_uploads` (`/app/data/uploads`) contains local uploaded files when S3 is not configured.

Treat both as sensitive data surfaces. Back them up and restore them together, restrict host and Docker daemon access, and avoid sharing raw volume archives, full `pg_dump` output, or upload tarballs outside the trusted operator group. For support cases, prefer schema/version/error snippets and redact secrets, tokens, user content, and uploaded file data.

### Cookies

| Variable | Description |
Expand All @@ -114,8 +141,12 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `BIND_HOST` | `127.0.0.1` | Docker Compose host interface for published backend/frontend ports. Use a specific LAN IP for private LAN only; do not use `0.0.0.0` for normal public exposure. |
| `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND` | empty | Emergency override for `BIND_HOST=0.0.0.0`; still requires the rest of the preflight hardening checks to pass. |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. |
| `MULTICA_TRUSTED_PROXIES` | empty | CIDRs whose forwarded headers may be trusted for autopilot webhook rate limiting. Leave empty for direct/raw backend exposure. Behind a proxy, use exact proxy/CDN CIDRs only; never `0.0.0.0/0` or `::/0`. |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Separate CIDRs for auth endpoint per-IP rate limiting. Required behind a reverse proxy to avoid all users sharing one proxy bucket. Use exact proxy/CDN CIDRs only. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |

### CLI / Daemon
Expand Down Expand Up @@ -272,7 +303,18 @@ REMOTE_API_URL=http://localhost:8080 pnpm start

## Reverse Proxy

In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
In production, keep Docker published ports on `127.0.0.1` and put a reverse proxy or tunnel in front of both the backend and frontend. The proxy must terminate TLS, forward `/ws` with WebSocket `Upgrade` headers, and be the only public listener.

After the proxy is configured, set exact origins and trusted proxy CIDRs in `.env`:

```bash
FRONTEND_ORIGIN=https://multica.example.com
CORS_ALLOWED_ORIGINS=https://multica.example.com
MULTICA_TRUSTED_PROXIES=127.0.0.1/32
RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32
```

Replace `127.0.0.1/32` with the actual same-host proxy, load balancer, or CDN CIDRs. Do not use `0.0.0.0/0` or `::/0`; that lets untrusted clients spoof forwarded IP headers.

### Caddy (Recommended)

Expand Down Expand Up @@ -373,6 +415,8 @@ When using separate domains for frontend and backend, set these environment vari
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
MULTICA_TRUSTED_PROXIES=127.0.0.1/32
RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32

# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
REMOTE_API_URL=https://api.example.com
Expand All @@ -382,14 +426,17 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws

## LAN / Non-localhost Access

By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
By default, Multica publishes Docker ports on `127.0.0.1`. If you access it from another machine on a trusted private LAN (e.g. `http://192.168.1.100:3000`), bind to one specific LAN IP and tell the backend to accept that browser origin:

```bash
# .env — replace with your server's LAN IP
BIND_HOST=192.168.1.100
FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```

Do not use `BIND_HOST=0.0.0.0` for normal LAN/public setup. Before restarting, rotate example secrets, keep `APP_ENV=production`, leave `MULTICA_DEV_VERIFICATION_CODE` empty, configure Resend or SMTP, and lock down `ALLOW_SIGNUP` / `DISABLE_WORKSPACE_CREATION` after bootstrap. The preflight checks these before Compose starts.

Then restart the stack:

```bash
Expand Down
Loading
Loading