This guide covers running LLM Council Plus as a single Docker container — suitable for home servers, VPS instances, and any environment where you want a persistent, auto-restarting deployment.
git clone https://github.com/jacob-bd/llm-council-plus.git
cd llm-council-plus
docker compose up -d --buildThen open http://localhost:8001 and configure your API keys in Settings.
The first build takes a few minutes (Python deps + frontend compile). Subsequent builds reuse the cache and are much faster.
The container runs everything in one process:
- The React frontend is compiled at build time and served as static files by the FastAPI backend.
- The FastAPI backend listens on port
8001and serves both the UI and all/api/*routes. - A startup script (
docker-entrypoint.sh) injects the runtime API URL into the frontend config before uvicorn starts.
All user data lives in /app/data inside the container, which is mounted to ./data on the host:
volumes:
- ./data:/app/dataThis covers:
| Path inside container | Host path | Contents |
|---|---|---|
/app/data/settings.json |
./data/settings.json |
API keys, council config, all settings |
/app/data/conversations/ |
./data/conversations/ |
Full conversation history |
Your data survives:
- Container restarts
- Image rebuilds (
docker compose up -d --build) docker compose downand back up
Your data is lost only if you delete ./data/ on the host. Never do this unless you intend to wipe everything.
⚠️ ./data/settings.jsoncontains your API keys in plain text. Keep this directory out of version control (it is already in.gitignore).
Set these in a .env file in the project root, or inline in docker-compose.yml.
| Variable | Default | Description |
|---|---|---|
BACKEND_HOST |
(empty) | Full URL of the backend, e.g. https://api.example.com. Leave empty when frontend and API share the same domain/port. |
FRONTEND_HOST |
(empty) | Comma-separated allowed CORS origins, e.g. https://council.example.com. Leave empty when serving both from the same origin. |
LLM_COUNCIL_ADMIN_TOKEN |
(empty) | Required for remote access to settings export/import/reset. When unset, those admin endpoints only accept direct loopback clients and reject proxied external clients. |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama endpoint. Must be changed when using Docker — see below. |
FRONTEND_DIST_DIR |
/app/frontend/dist |
Path to the compiled frontend. Do not change unless you know what you're doing. |
LLM_COUNCIL_BIND_HOST and LLM_COUNCIL_BIND_PORT apply only to the local python -m backend.main dev launcher. Docker starts uvicorn directly with --host 0.0.0.0 --port 8001, so use Docker port publishing or reverse proxy settings instead of those variables for container deployments.
# Leave both empty when accessing via http://YOUR_HOST_IP:8001
BACKEND_HOST=
FRONTEND_HOST=
# Required if using local Ollama with Docker
OLLAMA_BASE_URL=http://host.docker.internal:11434
# Required if you need Backup & Reset admin actions from another device or via a reverse proxy
LLM_COUNCIL_ADMIN_TOKEN=replace-with-a-long-random-tokenOllama runs on your host machine at localhost:11434. From inside the container, localhost refers to the container itself — not your Mac/Linux host — so Ollama will be unreachable.
Fix: Set OLLAMA_BASE_URL to the Docker host gateway:
OLLAMA_BASE_URL=http://host.docker.internal:11434host.docker.internal is automatically resolved to your host machine by Docker Desktop (macOS and Windows). On Linux hosts, add this to the docker-compose.yml service:
extra_hosts:
- "host.docker.internal:host-gateway"Point your reverse proxy (nginx, Caddy, Traefik) to http://127.0.0.1:8001.
council.example.com {
reverse_proxy 127.0.0.1:8001
}server {
listen 80;
server_name council.example.com;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required for SSE (streaming responses)
proxy_buffering off;
proxy_cache off;
}
}Important: Disable proxy buffering (
proxy_buffering off) — the app uses Server-Sent Events for real-time streaming. Buffered proxies will cause the UI to hang until a response completes.
When using a reverse proxy with a custom domain, BACKEND_HOST and FRONTEND_HOST can stay empty as long as the frontend and API are on the same domain.
If you split them (e.g., API on api.example.com, UI on council.example.com), set both:
BACKEND_HOST=https://api.example.com
FRONTEND_HOST=https://council.example.comSettings export/import/reset are admin endpoints because settings exports include plaintext API keys. If you need to use those Backup & Reset actions through a reverse proxy or from another device, set LLM_COUNCIL_ADMIN_TOKEN and send Authorization: Bearer <token> with those requests. Without the token, proxied external clients are rejected even though the reverse proxy connects to the backend over 127.0.0.1.
Pull the latest code and rebuild. Your data is untouched.
git pull
docker compose up -d --buildDocker layer caching means only changed layers rebuild. A typical upgrade (Python deps unchanged) takes under 30 seconds.
The docker-compose.yml sets restart: unless-stopped, so the container restarts automatically after a system reboot — as long as Docker itself starts at boot.
- Docker Desktop (macOS/Windows): Enable "Start Docker Desktop when you log in" in Docker Desktop preferences.
- Linux (Docker Engine): Enable the Docker daemon:
sudo systemctl enable docker
- The container runs as a non-root user (
appuser) for reduced attack surface. - A healthcheck polls
/api/healthevery 30 seconds. Docker will report the container asunhealthyif the backend stops responding, andrestart: unless-stoppedwill restart it. - API keys are stored in plain text in
./data/settings.json. Do not expose port8001to the public internet without authentication (use a reverse proxy with auth, or restrict access via firewall).
docker compose logs -fdocker inspect --format='{{.State.Health.Status}}' llm-council-plus-app-1docker compose exec app bashdocker compose exec app whoami
# → appuserYou are likely accessing the app from a different origin than the one Docker is binding to. Either:
- Access via
http://YOUR_HOST_IP:8001(notlocalhostfrom another machine) - Or set
FRONTEND_HOSTandBACKEND_HOSTappropriately for your split-origin setup
Docker creates the ./data directory as root when the container first starts. On older images (before this was fixed in the entrypoint), appuser inside the container couldn't write to it.
If you're running an older image, fix it manually:
chmod 777 ./data
docker compose restartRebuilding from the latest image (docker compose up -d --build) fixes this permanently — the entrypoint now corrects ownership automatically on every startup.
Add proxy_buffering off; and proxy_cache off; to your nginx location block — see the nginx example above.