diff --git a/.env.example b/.env.example
index 2bfe58cc4..47f43aa8d 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,14 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
+# Self-host image channel
+# Default stable release channel. Pin to an exact release like v0.2.4 if you
+# want to stay on a specific version. If the selected tag has not been
+# published to GHCR yet, use make selfhost-build / the build override instead.
+MULTICA_IMAGE_TAG=latest
+MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
+MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
+
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
@@ -44,10 +52,12 @@ RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
# Google OAuth
+# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
+# changing it only requires restarting the backend / compose stack. No web
+# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
-NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=
@@ -90,10 +100,9 @@ NEXT_PUBLIC_WS_URL=
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
-# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
-# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
-# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
-NEXT_PUBLIC_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.
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
@@ -109,4 +118,3 @@ POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=
-
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cdf99f77e..7b30cd6df 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,10 +10,14 @@ on:
permissions:
contents: write
+ packages: write
jobs:
- release:
+ verify:
runs-on: ubuntu-latest
+ outputs:
+ tag_name: ${{ steps.release_meta.outputs.tag_name }}
+ is_stable: ${{ steps.release_meta.outputs.is_stable }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -21,6 +25,8 @@ jobs:
fetch-depth: 0
- name: Validate tag name
+ id: release_meta
+ shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
@@ -32,6 +38,12 @@ jobs:
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
+ echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
+ if [[ "$tag" == *-* ]]; then
+ echo "is_stable=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "is_stable=true" >> "$GITHUB_OUTPUT"
+ fi
- name: Setup Go
uses: actions/setup-go@v5
@@ -42,6 +54,21 @@ jobs:
- name: Run tests
run: cd server && go test ./...
+ release:
+ needs: verify
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: server/go.mod
+ cache-dependency-path: server/go.sum
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -51,6 +78,91 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
+ docker-images:
+ needs: verify
+ runs-on: ubuntu-latest
+ concurrency:
+ group: release-docker-images-${{ github.ref }}
+ cancel-in-progress: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Setup Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Compute backend image tags
+ id: meta_backend
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/multica-backend
+ flavor: |
+ latest=false
+ tags: |
+ type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
+ type=raw,value=${{ needs.verify.outputs.tag_name }}
+ type=sha,prefix=sha-
+ labels: |
+ org.opencontainers.image.title=Multica Backend
+ org.opencontainers.image.description=Multica self-hosted backend
+
+ - name: Build and push backend image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile
+ pull: true
+ push: true
+ platforms: linux/amd64,linux/arm64
+ labels: ${{ steps.meta_backend.outputs.labels }}
+ tags: ${{ steps.meta_backend.outputs.tags }}
+ cache-from: type=gha,scope=release-backend
+ cache-to: type=gha,mode=max,scope=release-backend
+ build-args: |
+ VERSION=${{ needs.verify.outputs.tag_name }}
+ COMMIT=${{ github.sha }}
+
+ - name: Compute web image tags
+ id: meta_web
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/multica-web
+ flavor: |
+ latest=false
+ tags: |
+ type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
+ type=raw,value=${{ needs.verify.outputs.tag_name }}
+ type=sha,prefix=sha-
+ labels: |
+ org.opencontainers.image.title=Multica Web
+ org.opencontainers.image.description=Multica self-hosted web frontend
+
+ - name: Build and push web image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.web
+ pull: true
+ push: true
+ platforms: linux/amd64,linux/arm64
+ labels: ${{ steps.meta_web.outputs.labels }}
+ tags: ${{ steps.meta_web.outputs.tags }}
+ cache-from: type=gha,scope=release-web
+ cache-to: type=gha,mode=max,scope=release-web
+ build-args: |
+ REMOTE_API_URL=http://backend:8080
+ NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
+
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it
diff --git a/Dockerfile.web b/Dockerfile.web
index d1f507c5b..afae4f91b 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -36,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
-ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_WS_URL
+ARG NEXT_PUBLIC_APP_VERSION=dev
ENV REMOTE_API_URL=$REMOTE_API_URL
-ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
+ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)
diff --git a/Makefile b/Makefile
index c843b6a97..40fb1ab5f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-stop
+.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -52,7 +52,7 @@ makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
-selfhost: ## Create .env if needed, then build and start the local self-hosted stack
+selfhost: ## Create .env if needed, then pull and start the official self-hosted images
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -64,8 +64,58 @@ selfhost: ## Create .env if needed, then build and start the local self-hosted s
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
+ @echo "==> Pulling official Multica images..."
+ @if ! docker compose -f docker-compose.selfhost.yml pull; then \
+ echo ""; \
+ echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
+ echo "If this is before the first GHCR release, build from the current checkout:"; \
+ echo " make selfhost-build"; \
+ exit 1; \
+ fi
@echo "==> Starting Multica via Docker Compose..."
- docker compose -f docker-compose.selfhost.yml up -d --build
+ 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 \
+ break; \
+ fi; \
+ sleep 2; \
+ done
+ @if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
+ echo ""; \
+ echo "✓ Multica is running!"; \
+ echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
+ echo " Backend: http://localhost:$${PORT:-8080}"; \
+ 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}"; \
+ echo ""; \
+ echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
+ echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
+ echo ""; \
+ echo "Next — install the CLI and connect your machine:"; \
+ echo " brew install multica-ai/tap/multica"; \
+ echo " multica setup self-host"; \
+ else \
+ echo ""; \
+ echo "Services are still starting. Check logs:"; \
+ echo " docker compose -f docker-compose.selfhost.yml logs"; \
+ fi
+
+selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
+ @if [ ! -f .env ]; then \
+ echo "==> Creating .env from .env.example..."; \
+ cp .env.example .env; \
+ JWT=$$(openssl rand -hex 32); \
+ if [ "$$(uname)" = "Darwin" ]; then \
+ sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
+ else \
+ sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
+ fi; \
+ echo "==> Generated random JWT_SECRET"; \
+ fi
+ @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 \
@@ -82,6 +132,9 @@ selfhost: ## Create .env if needed, then build and start the local self-hosted s
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
+ echo "Built images locally via docker-compose.selfhost.build.yml."; \
+ echo "Local tags: multica-backend:dev and multica-web:dev."; \
+ echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
diff --git a/README.md b/README.md
index 9ad2b10f2..b6b5c639d 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
-> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
+> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
+> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
---
diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md
index f6b5f7fdf..653bcd0a2 100644
--- a/SELF_HOSTING.md
+++ b/SELF_HOSTING.md
@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
-This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
+This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
@@ -54,6 +54,10 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
+By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
+If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
+`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
+
Once ready:
- **Frontend:** http://localhost:3000
@@ -69,6 +73,8 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** 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.
+Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
+
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon
@@ -156,14 +162,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
-## Rebuilding After Updates
+## Upgrading
```bash
-git pull
-make selfhost
+docker compose -f docker-compose.selfhost.yml pull
+docker compose -f docker-compose.selfhost.yml up -d
```
-Migrations run automatically on backend startup.
+Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
+If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -186,6 +193,7 @@ JWT_SECRET=$(openssl rand -hex 32)
Then start everything:
```bash
+docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md
index b4b4bf4eb..ef86e18e7 100644
--- a/SELF_HOSTING_ADVANCED.md
+++ b/SELF_HOSTING_ADVANCED.md
@@ -42,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
+Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
+
+### Signup Controls (Optional)
+
+| Variable | Description |
+|----------|-------------|
+| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
+| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
+| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
+
+Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
+
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -234,7 +246,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
-# Frontend (set before building the frontend image)
+# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -250,15 +262,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
-Then rebuild:
+Then restart the stack:
```bash
-docker compose -f docker-compose.selfhost.yml up -d --build
+docker compose -f docker-compose.selfhost.yml up -d
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
-> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
+> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
## Health Check
@@ -274,8 +286,9 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
-git pull
-docker compose -f docker-compose.selfhost.yml up -d --build
+docker compose -f docker-compose.selfhost.yml pull
+docker compose -f docker-compose.selfhost.yml up -d
```
-Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
+Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
+If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
diff --git a/apps/docs/content/docs/getting-started/self-hosting.mdx b/apps/docs/content/docs/getting-started/self-hosting.mdx
index 227571c81..c6de2db3d 100644
--- a/apps/docs/content/docs/getting-started/self-hosting.mdx
+++ b/apps/docs/content/docs/getting-started/self-hosting.mdx
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
-This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
+This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -53,13 +53,17 @@ make selfhost
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
+By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
+If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
+`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
+
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
-If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
+If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
### Step 2 — Log In
@@ -70,6 +74,8 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** 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.
+Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
+
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
@@ -151,14 +157,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
Your local Docker services are unaffected. Stop them separately if you no longer need them.
-## Rebuilding After Updates
+## Upgrading
```bash
-git pull
-make selfhost
+docker compose -f docker-compose.selfhost.yml pull
+docker compose -f docker-compose.selfhost.yml up -d
```
-Migrations run automatically on backend startup.
+Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
+If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -191,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
+Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
+
+### Signup Controls (Optional)
+
+| Variable | Description |
+|----------|-------------|
+| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
+| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
+| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
+
+Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
+
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index ceebf15bf..05608b218 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
+import { useConfigStore } from "@multica/core/config";
import { workspaceKeys } from "@multica/core/workspace/queries";
import {
paths,
@@ -24,11 +25,10 @@ import { Loader2 } from "lucide-react";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
-const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
-
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
+ const googleClientId = useConfigStore((state) => state.googleClientId);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const searchParams = useSearchParams();
diff --git a/apps/web/features/landing/i18n/context.tsx b/apps/web/features/landing/i18n/context.tsx
index ac6754925..eb5e90669 100644
--- a/apps/web/features/landing/i18n/context.tsx
+++ b/apps/web/features/landing/i18n/context.tsx
@@ -1,11 +1,15 @@
"use client";
-import { createContext, useContext, useState, useCallback } from "react";
-import { en } from "./en";
-import { zh } from "./zh";
+import { createContext, useContext, useState, useCallback, useMemo } from "react";
+import { useConfigStore } from "@multica/core/config";
+import { createEnDict } from "./en";
+import { createZhDict } from "./zh";
import type { LandingDict, Locale } from "./types";
-const dictionaries: Record = { en, zh };
+const dictionaryFactories: Record LandingDict> = {
+ en: createEnDict,
+ zh: createZhDict,
+};
const COOKIE_NAME = "multica-locale";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
@@ -26,6 +30,11 @@ export function LocaleProvider({
initialLocale?: Locale;
}) {
const [locale, setLocaleState] = useState(initialLocale);
+ const allowSignup = useConfigStore((state) => state.allowSignup);
+ const t = useMemo(
+ () => dictionaryFactories[locale](allowSignup),
+ [allowSignup, locale],
+ );
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
@@ -34,7 +43,7 @@ export function LocaleProvider({
return (
{children}
diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts
index ac5661d24..878516c40 100644
--- a/apps/web/features/landing/i18n/en.ts
+++ b/apps/web/features/landing/i18n/en.ts
@@ -1,9 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
-export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
-
-export const en: LandingDict = {
+export function createEnDict(allowSignup: boolean): LandingDict {
+ return {
header: {
github: "GitHub",
login: "Log in",
@@ -122,8 +121,8 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
- title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
- description: ALLOW_SIGNUP
+ title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace",
+ description: allowSignup
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
},
@@ -726,4 +725,5 @@ export const en: LandingDict = {
},
],
},
-};
+ };
+}
diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts
index 4e2d4be10..c1c25065d 100644
--- a/apps/web/features/landing/i18n/zh.ts
+++ b/apps/web/features/landing/i18n/zh.ts
@@ -1,9 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
-export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
-
-export const zh: LandingDict = {
+export function createZhDict(allowSignup: boolean): LandingDict {
+ return {
header: {
github: "GitHub",
login: "\u767b\u5f55",
@@ -122,8 +121,8 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
- title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
- description: ALLOW_SIGNUP
+ title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
+ description: allowSignup
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
},
@@ -726,4 +725,5 @@ export const zh: LandingDict = {
},
],
},
-};
+ };
+}
diff --git a/docker-compose.selfhost.build.yml b/docker-compose.selfhost.build.yml
new file mode 100644
index 000000000..a80dffdf7
--- /dev/null
+++ b/docker-compose.selfhost.build.yml
@@ -0,0 +1,19 @@
+# Development override: build the backend/web images from the current checkout
+# instead of pulling the official GHCR images.
+
+services:
+ backend:
+ image: multica-backend:dev
+ build:
+ context: .
+ dockerfile: Dockerfile
+
+ frontend:
+ image: multica-web:dev
+ build:
+ context: .
+ dockerfile: Dockerfile.web
+ args:
+ REMOTE_API_URL: http://backend:8080
+ NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
+ NEXT_PUBLIC_APP_VERSION: dev
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index 428c28fc7..bdbd01afa 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -29,9 +29,7 @@ services:
retries: 5
backend:
- build:
- context: .
- dockerfile: Dockerfile
+ image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
postgres:
condition: service_healthy
@@ -61,13 +59,7 @@ services:
restart: unless-stopped
frontend:
- build:
- context: .
- dockerfile: Dockerfile.web
- args:
- REMOTE_API_URL: http://backend:8080
- NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
- NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
+ image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
depends_on:
- backend
ports:
diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts
index 689b6ebe1..ec1e6e63b 100644
--- a/packages/core/api/client.ts
+++ b/packages/core/api/client.ts
@@ -702,6 +702,8 @@ export class ApiClient {
// App Config
async getConfig(): Promise<{
cdn_domain: string;
+ allow_signup: boolean;
+ google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
}> {
diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts
index 18817e778..2afbcb744 100644
--- a/packages/core/config/index.ts
+++ b/packages/core/config/index.ts
@@ -3,12 +3,19 @@ import { useStore } from "zustand";
interface ConfigState {
cdnDomain: string;
+ allowSignup: boolean;
+ googleClientId: string;
setCdnDomain: (domain: string) => void;
+ setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void;
}
export const configStore = createStore((set) => ({
cdnDomain: "",
+ allowSignup: true,
+ googleClientId: "",
setCdnDomain: (domain) => set({ cdnDomain: domain }),
+ setAuthConfig: ({ allowSignup, googleClientId = "" }) =>
+ set({ allowSignup, googleClientId }),
}));
export function useConfigStore(): ConfigState;
diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx
index abcd2b0b2..ab9f37788 100644
--- a/packages/core/platform/auth-initializer.tsx
+++ b/packages/core/platform/auth-initializer.tsx
@@ -47,6 +47,10 @@ export function AuthInitializer({
.getConfig()
.then((cfg) => {
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
+ configStore.getState().setAuthConfig({
+ allowSignup: cfg.allow_signup,
+ googleClientId: cfg.google_client_id,
+ });
if (cfg.posthog_key) {
initAnalytics({ key: cfg.posthog_key, host: cfg.posthog_host || "" });
}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index e3ea2044b..f5e5439f6 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -39,6 +39,55 @@ function Get-LatestVersion {
}
}
+function Get-SelfHostRef {
+ if ($env:MULTICA_SELFHOST_REF) {
+ return $env:MULTICA_SELFHOST_REF
+ }
+
+ $latest = Get-LatestVersion
+ if ($latest) {
+ return $latest
+ }
+
+ return "main"
+}
+
+function Checkout-ServerRef {
+ param([string]$Ref)
+
+ if ($Ref -eq "main") {
+ git fetch origin main --depth 1 2>$null
+ git checkout --force main 2>$null
+ git reset --hard origin/main 2>$null
+ return
+ }
+
+ git fetch origin --tags --force 2>$null
+ $tagRef = "refs/tags/$Ref"
+ git show-ref --verify --quiet $tagRef 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ git checkout --force $Ref 2>$null
+ return
+ }
+
+ git fetch origin $Ref --depth 1 2>$null
+ git checkout --force $Ref 2>$null
+}
+
+function Pull-OfficialSelfHostImages {
+ docker compose -f docker-compose.selfhost.yml pull
+ if ($LASTEXITCODE -eq 0) {
+ return
+ }
+
+ Write-Host ""
+ Write-Warn "Official images for the selected self-host channel are not published yet."
+ Write-Host "This can happen before the first GHCR release is available."
+ Write-Host "From $InstallDir, build from source instead:"
+ Write-Host " docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build"
+ exit 1
+}
+
# ---------------------------------------------------------------------------
# CLI Installation
# ---------------------------------------------------------------------------
@@ -202,14 +251,12 @@ After installing Docker, re-run this script with `$env:MULTICA_MODE="local"`.
# ---------------------------------------------------------------------------
function Install-Server {
Write-Info "Setting up Multica server..."
+ $serverRef = Get-SelfHostRef
+ Write-Info "Using self-host assets from $serverRef..."
if (Test-Path (Join-Path $InstallDir ".git")) {
Write-Info "Updating existing installation at $InstallDir..."
Write-Warn "Any local changes in $InstallDir will be overwritten."
- Push-Location $InstallDir
- git fetch origin main --depth 1 2>$null
- git reset --hard origin/main 2>$null
- Pop-Location
} else {
Write-Info "Cloning Multica repository..."
if (-not (Test-CommandExists "git")) {
@@ -226,9 +273,9 @@ function Install-Server {
git clone --depth 1 $RepoUrl $InstallDir
}
- Write-Ok "Repository ready at $InstallDir"
-
Push-Location $InstallDir
+ Checkout-ServerRef $serverRef
+ Write-Ok "Repository ready at $InstallDir ($serverRef)"
if (-not (Test-Path ".env")) {
Write-Info "Creating .env with random JWT_SECRET..."
@@ -240,8 +287,10 @@ function Install-Server {
Write-Ok "Using existing .env"
}
+ Write-Info "Pulling official Multica images..."
+ Pull-OfficialSelfHostImages
Write-Info "Starting Multica services (this may take a few minutes on first run)..."
- docker compose -f docker-compose.selfhost.yml up -d --build
+ docker compose -f docker-compose.selfhost.yml up -d
Write-Info "Waiting for backend to be ready..."
$ready = $false
diff --git a/scripts/install.sh b/scripts/install.sh
index 0c4a45e3d..4f1c085d4 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -140,6 +140,55 @@ get_latest_version() {
curl -sI "$REPO_WEB_URL/releases/latest" 2>/dev/null | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n' || true
}
+get_selfhost_ref() {
+ if [ -n "${MULTICA_SELFHOST_REF:-}" ]; then
+ printf '%s' "$MULTICA_SELFHOST_REF"
+ return
+ fi
+
+ local latest
+ latest=$(get_latest_version)
+ if [ -n "$latest" ]; then
+ printf '%s' "$latest"
+ return
+ fi
+
+ printf '%s' "main"
+}
+
+checkout_server_ref() {
+ local ref="$1"
+
+ if [ "$ref" = "main" ]; then
+ git fetch origin main --depth 1 2>/dev/null || true
+ git checkout --force main 2>/dev/null || true
+ git reset --hard origin/main 2>/dev/null || true
+ return
+ fi
+
+ git fetch origin --tags --force 2>/dev/null || true
+ if git rev-parse --verify --quiet "refs/tags/$ref" >/dev/null; then
+ git checkout --force "$ref" 2>/dev/null || git checkout --force "tags/$ref" 2>/dev/null || true
+ return
+ fi
+
+ git fetch origin "$ref" --depth 1 2>/dev/null || true
+ git checkout --force "$ref" 2>/dev/null || true
+}
+
+pull_official_selfhost_images() {
+ if docker compose -f docker-compose.selfhost.yml pull; then
+ return
+ fi
+
+ echo ""
+ warn "Official images for the selected self-host channel are not published yet."
+ echo "This can happen before the first GHCR release is available."
+ echo "From $INSTALL_DIR, build from source instead:"
+ echo " docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build"
+ exit 1
+}
+
upgrade_cli_brew() {
info "Upgrading Multica CLI via Homebrew..."
brew update 2>/dev/null || true
@@ -221,12 +270,13 @@ After installing Docker, re-run this script with --with-server."
# ---------------------------------------------------------------------------
setup_server() {
info "Setting up Multica server..."
+ local server_ref
+ server_ref=$(get_selfhost_ref)
+ info "Using self-host assets from ${server_ref}..."
if [ -d "$INSTALL_DIR/.git" ]; then
info "Updating existing installation at $INSTALL_DIR..."
cd "$INSTALL_DIR"
- git fetch origin main --depth 1 2>/dev/null || true
- git reset --hard origin/main 2>/dev/null || true
else
info "Cloning Multica repository..."
if ! command_exists git; then
@@ -242,7 +292,9 @@ setup_server() {
cd "$INSTALL_DIR"
fi
- ok "Repository ready at $INSTALL_DIR"
+ checkout_server_ref "$server_ref"
+
+ ok "Repository ready at $INSTALL_DIR ($server_ref)"
# Generate .env if needed
if [ ! -f .env ]; then
@@ -261,8 +313,10 @@ setup_server() {
fi
# Start Docker Compose
+ info "Pulling official Multica images..."
+ pull_official_selfhost_images
info "Starting Multica services (this may take a few minutes on first run)..."
- docker compose -f docker-compose.selfhost.yml up -d --build
+ docker compose -f docker-compose.selfhost.yml up -d
# Wait for health check
info "Waiting for backend to be ready..."
diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go
index 3815e7db8..9971164b7 100644
--- a/server/internal/handler/config.go
+++ b/server/internal/handler/config.go
@@ -7,6 +7,11 @@ import (
type AppConfig struct {
CdnDomain string `json:"cdn_domain"`
+ // Public auth config consumed by the web app at runtime so self-hosted
+ // deployments do not need to rebuild the frontend image when operators
+ // toggle signup or wire Google OAuth.
+ AllowSignup bool `json:"allow_signup"`
+ GoogleClientID string `json:"google_client_id,omitempty"`
// PostHog public config for the frontend. The key is the same Project
// API Key the backend uses; returning it here (instead of baking it
@@ -18,7 +23,10 @@ type AppConfig struct {
}
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
- config := AppConfig{}
+ config := AppConfig{
+ AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
+ GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ }
if h.Storage != nil {
config.CdnDomain = h.Storage.CdnDomain()
}
diff --git a/server/internal/handler/config_test.go b/server/internal/handler/config_test.go
new file mode 100644
index 000000000..df4f2f852
--- /dev/null
+++ b/server/internal/handler/config_test.go
@@ -0,0 +1,48 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) {
+ origStorage := testHandler.Storage
+ testHandler.Storage = &mockStorage{}
+ defer func() { testHandler.Storage = origStorage }()
+
+ t.Setenv("ALLOW_SIGNUP", "false")
+ t.Setenv("GOOGLE_CLIENT_ID", "google-client-id")
+ t.Setenv("POSTHOG_API_KEY", "phc_test")
+ t.Setenv("POSTHOG_HOST", "https://eu.i.posthog.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
+ w := httptest.NewRecorder()
+
+ testHandler.GetConfig(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var cfg AppConfig
+ if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
+ t.Fatalf("decode config: %v", err)
+ }
+
+ if cfg.CdnDomain != "cdn.example.com" {
+ t.Fatalf("cdn_domain: want cdn.example.com, got %q", cfg.CdnDomain)
+ }
+ if cfg.AllowSignup {
+ t.Fatalf("allow_signup: want false, got true")
+ }
+ if cfg.GoogleClientID != "google-client-id" {
+ t.Fatalf("google_client_id: want google-client-id, got %q", cfg.GoogleClientID)
+ }
+ if cfg.PosthogKey != "phc_test" {
+ t.Fatalf("posthog_key: want phc_test, got %q", cfg.PosthogKey)
+ }
+ if cfg.PosthogHost != "https://eu.i.posthog.com" {
+ t.Fatalf("posthog_host: want https://eu.i.posthog.com, got %q", cfg.PosthogHost)
+ }
+}