/ Deployment guide

Self-hosted in 5 minutes

Community Edition

Savvina AI Community Edition ships as a Docker Compose stack with pre-built images on Docker Hub. One password in .env, one pull, one command. This guide covers a server deployment on Linux behind an HTTPS reverse proxy; production or commercial use requires a commercial or evaluation licence.

Pre-built images

Published on every release since v2.1.0 for linux/amd64 and linux/arm64, so Apple Silicon, Raspberry Pi and most NAS boxes are covered. Latest: v2.1.1 — see the changelog.

Prerequisites

  • Linux server (Ubuntu 22.04+ or RHEL 9+ recommended)
  • Docker Engine 24+ with the Compose plugin v2.24.4 or newer (docker compose version prints it; the port lockdown in step 06 uses !override, which older plugins reject)
  • At least 4 GB RAM — 8 GB+ recommended under concurrent load
  • At least 30 GB disk — the backend image is about 1 GB (fastembed ONNX embedding model baked in); budget additional space for the PostgreSQL data volume, build cache, and OS
  • A domain name with DNS pointing to the server (for HTTPS)
  • At least one LLM API key (Groq and Google Gemini have free tiers)

Evaluating on a laptop instead? Docker Desktop (macOS, Windows, Linux) includes Compose v2 and works the same way — skip the HTTPS and port steps and open the UI on localhost.

The same requirements apply for a production rollout — but you don't have to deploy it yourself. We set Savvina up with you and issue an evaluation licence so you can run it on real data first; see the production checklist below for the production-specific settings, or talk to us to get started.

Prepare the environment

Clone the repository (it holds the Compose file and .env.example; the application itself comes from Docker Hub in step 03), copy the example env file, and record your host user so mounted volumes stay writable:

git clone https://github.com/savvina-ai/savvina
cd savvina
cp .env.example .env
chmod 600 .env

# Linux / WSL: run the containers as your user
printf '\nLOCAL_UID=%s\nLOCAL_GID=%s\n' "$(id -u)" "$(id -g)" >> .env

Open .env and set the one value it needs — a password for the bundled app database:

APP_DB_PASSWORD

Password for the internal PostgreSQL app database (local-db profile). Compose derives the connection URL from it.

python -c "import secrets; print(secrets.token_urlsafe(24))"

Encryption and JWT keys are generated for you. On first boot the backend createsENCRYPTION_KEY and JWT_SECRET_KEY and persists them to /app/data/secrets.env in the data volume — do not add them to .env. Back up the encryption key after the first start (step 07); losing it makes every stored credential and API key permanently unreadable. LLM API keys are not read from .env either — you enter them in the UI, where they are stored encrypted.

Choose a database mode

Option A — Local Docker PostgreSQL (default)

Nothing more to do. .env.example already ships COMPOSE_PROFILES=local-db, which starts the bundled db container, and the connection URL is derived from your password:

COMPOSE_PROFILES=local-db
APP_DB_PASSWORD=<strong-password>
# DATABASE_URL is derived: postgresql+asyncpg://savvina:<strong-password>@db:5432/savvina_app

Option B — External / Managed PostgreSQL

For AWS RDS, GCP Cloud SQL, Supabase, Neon, Azure, etc.: comment out COMPOSE_PROFILES (the bundled db container will not start), set APP_DB_PASSWORD to any placeholder value — Compose still requires it to be non-empty — and point DATABASE_URL at your provider:

# COMPOSE_PROFILES=local-db
APP_DB_PASSWORD=unused   # must be non-empty; ignored once DATABASE_URL is set
DATABASE_URL=postgresql+asyncpg://USER:[email protected]:5432/savvina_app
# Append ?ssl=require if your provider enforces SSL (most do)

The database user needs CREATE TABLE, ALTER TABLE, and CREATE INDEX privileges. Migrations run automatically on startup.

Pull and start

On an internet-facing server, create the port override from step 06 before the first start — until then, whoever opens the UI first becomes the admin.

Pull the pre-built images and start the stack. Nothing compiles, and the embedding model is already inside the backend image:

docker compose pull
docker compose up -d --no-build

latest is the newest release. To pin one, add SAVVINA_IMAGE_TAG=v2.1.1 (any tag from v2.1.0 on, listed on the releases page; earlier releases have no images) to .env before pulling — this pins the code, dependencies and migrations together.

Building from source instead (you changed the code, or your host cannot reach Docker Hub) is one command, but the first build takes a while:

docker compose up --build -d

This starts the core services: backend (FastAPI on port 8000), frontend (React/Nginx, published on APP_PORT, default 3000), and db (PostgreSQL, local-db profile only). Migrations run on every start, so the first boot can take a minute or two.

Verify all services are healthy:

docker compose ps

Confirm the backend started correctly — look for alembic's Context impl PostgresqlImpl line, then Embedding model loaded and Uvicorn running (these need LOG_LEVEL=INFO, the default):

docker compose logs backend --tail 30

Create the admin account

Open http://localhost:3000 on the server — from your workstation, tunnel it with ssh -L 3000:127.0.0.1:3000 you@server — or your proxy's address once step 05 is done. The first person to open the UI is taken to theInitial setup screen. Enter an email, an optional name and a password (at least 12 characters, with an uppercase letter, a digit and a special character). This screen appears only once — the registration endpoint closes permanently as soon as any account exists.

A two-step setup wizard then walks you through connecting a database and adding an LLM provider — paste your API key there, or later under Settings → LLM Providers.

Reaching the UI at any address other than http://localhost:3000 — a LAN IP, a different port, your domain — requires that exact origin in CORS_ORIGINS, or login fails with 403 Origin not allowed. Step 05 sets it for your domain.

Configure HTTPS

The stack serves plain HTTP and does not terminate TLS. For anything reachable beyond the server itself, put a TLS-terminating reverse proxy in front of the frontend and expose only the proxy.Caddy needs the least configuration — it obtains and renews Let's Encrypt certificates on its own:

Install Caddy:

apt install caddy -y

/etc/caddy/Caddyfile:

yourdomain.com {
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
    reverse_proxy 127.0.0.1:3000
}

Reload Caddy to pick up the new config:

systemctl reload caddy

Then tell the stack it is behind TLS. Add your domain to CORS_ORIGINS, set BEHIND_TLS_PROXY (this is what marks the session cookie Secure and turns on HSTS on API responses — the backend deliberately does not infer it from X-Forwarded-Proto), and name the range the proxy connects from so nginx takes the real client IP from X-Forwarded-For instead of rate-limiting every user as one:

CORS_ORIGINS=["https://yourdomain.com"]
BEHIND_TLS_PROXY=true
TRUSTED_LB_CIDR=172.16.0.0/12   # the Compose network's subnet — confirm it below

Docker hands each Compose project a subnet from its default address pools, so 172.16.0.0/12 covers the usual case but not a host that already runs several projects, where the pools spill into 192.168.0.0/16. Print the subnet the stack actually got (the network is named after the checkout directory plus savvina) and use that if it differs:

docker network inspect savvina_savvina -f '{{(index .IPAM.Config 0).Subnet}}'

Recreate the two containers so they pick up the new environment — a plain restart reuses the old one:

docker compose up -d backend frontend

Any other proxy (nginx, Traefik, HAProxy, a cloud load balancer) works the same way. Four requirements: send the Strict-Transport-Security header from the proxy (the backend only sends it on /api/*), disable response buffering and allow read timeouts of at least 300 s so chat streaming does not stall (900 s for semantic model generation), pass the client address in X-Forwarded-For, and set TRUSTED_LB_CIDR to the range the proxy really connects from — a cloud load balancer needs its own range.

Lock down exposed ports

By default, docker-compose.yaml publishes every port on all interfaces — 3000 (frontend; container port 8080), 8000 (backend API) and 5434 (app database), plus 5435/3307 (sample databases) and 11434 (Ollama) when those profiles are on. Rebind them to localhost with an extra Compose file, and keep the shipped docker-compose.override.yaml (!override needs Compose v2.24.4+):

# docker-compose.prod.yaml  (new file — keep the shipped docker-compose.override.yaml)
services:
  backend:
    ports: !override
      - "127.0.0.1:8000:8000"
  frontend:
    ports: !override
      - "127.0.0.1:${APP_PORT:-3000}:8080"
  db:
    ports: !override
      - "127.0.0.1:5434:5432"
  sample-postgres:
    ports: !override
      - "127.0.0.1:5435:5432"
  sample-mysql:
    ports: !override
      - "127.0.0.1:3307:3306"
  ollama:
    ports: !override
      - "127.0.0.1:11434:11434"

Then add it to .env:

COMPOSE_FILE=docker-compose.yaml:docker-compose.override.yaml:docker-compose.prod.yaml

Backups

All application state — user accounts, saved connections (with encrypted credentials), chat history, query cache — lives in volumes/app-db/ (local-db profile). Back it up regularly with pg_dump:

docker compose exec db pg_dump -U savvina savvina_app > backup.sql

If using an external/managed PostgreSQL, backups are your provider's responsibility — enable automated snapshots and PITR in your provider's console.

Also back up the generated secrets separately — a database dump is useless without the ENCRYPTION_KEY that decrypts the credentials in it:

docker compose exec backend cat /app/data/secrets.env

Sample databases (optional)

The test-dbs profile starts two pre-seeded databases so you can evaluate Savvina AI without connecting to a real data source:

ServiceEnginePortDatabase
sample-postgresPostgreSQL 165435savvina_test
sample-mysqlMySQL 8.03307sample_delivery

Add the profile in .env rather than on the command line — a --profile flag replaces the value from .env and would silently stop the app database from starting:

COMPOSE_PROFILES=local-db,test-dbs

# then
docker compose up -d

The sample containers publish ports 5435 and 3307 on all interfaces, and when SAMPLE_POSTGRES_PASSWORD and SAMPLE_MYSQL_PASSWORD are left empty they fall back to the published password savvina_demo (SAMPLE_MYSQL_ROOT_PASSWORD to savvina_demo_root) — on a reachable server set all three, or bind the ports to 127.0.0.1 as in step 06. Add them from Connections with user savvina. Demo data only — never a primary data source.

Local LLM with Ollama (optional)

Run a local Ollama instance if you want fully offline inference. A GPU is required for acceptable performance. Add the profile and the base URL in .env — setting OLLAMA_BASE_URL is what makes Ollama appear as a provider in the UI:

COMPOSE_PROFILES=local-db,local-llm
OLLAMA_BASE_URL=http://ollama:11434

# then
docker compose up -d
docker compose exec ollama ollama pull llama3

Configure it via Settings → LLM Providers → + Add Ollama (Local) config and select the pulled model.

Updating

Update the checkout, pull the new images and recreate the containers. Database schema migrations apply automatically on startup via alembic upgrade head — no manual migration step required. The volumes/ directory is never touched during updates.

git pull                     # compose file and .env.example for the new release
# Pinned? bump SAVVINA_IMAGE_TAG in .env first
docker compose pull
docker compose up -d --no-build

Building from source: git pull, then docker compose up --build -d. Each release is listed on the changelog.

Production checklist

VariableProduction value
SAVVINA_IMAGE_TAGPinned to a release tag (e.g. v2.1.1), not latest
BEHIND_TLS_PROXYtrue — required once the UI is served over https://
TRUSTED_LB_CIDRThe subnet your reverse proxy connects from — for a proxy on the same host, the savvina Compose network's subnet (usually inside 172.16.0.0/12; step 05 shows how to check)
CORS_ORIGINS["https://yourdomain.com"] — the exact origin users open
ENCRYPTION_KEYAuto-generated on first boot — backed up out of band, never reused across environments
LOG_LEVELWARNING or INFO (not DEBUG)
DEBUGfalse
DEFAULT_ROW_LIMIT500 or 1000
DEFAULT_QUERY_TIMEOUT30 (seconds)
VERIFY_SSLtrue (set false only behind a TLS-inspecting proxy)

Health check

The backend exposes a health endpoint used by Docker Compose and your load balancer:

curl http://localhost:8000/health
# {"status": "ok", "app": "Savvina AI"}

Corporate TLS proxies

If your network uses a TLS-intercepting proxy (Zscaler, Netskope, Palo Alto), LLM API calls may fail with certificate errors. Set in .env:

VERIFY_SSL=false

This affects only LLM provider HTTP clients — not the asyncpg database TLS stack.

Troubleshooting

Backend won't start

docker compose logs backend
  • Port 8000 already in use on the host
  • CORS_ORIGINS contains an https:// origin while BEHIND_TLS_PROXY is not true — the backend refuses to start rather than serve a Secure cookie over plain HTTP
  • A custom hostname over plain http:// in CORS_ORIGINS — only localhost, 127.0.0.1 and private IPv4 addresses are accepted without TLS

Login fails with 403 Origin not allowed

docker compose up -d backend
  • The browser origin (scheme + host + port) is not in CORS_ORIGINS. The default covers only http://localhost:3000 — a LAN IP, a different port, or your production domain all count as different origins.
  • CORS_ORIGINS was edited but the backend was only restarted — restart reuses the old environment; recreate it with the command above.

Build fails downloading the embedding model

  • Only applies to docker compose up --build. The model (BAAI/bge-small-en-v1.5) is fetched from HuggingFace at build time and anonymous downloads are rate-limited: put a free read-only token in .env as HF_TOKEN and rebuild, or skip the build entirely by pulling the pre-built images — the model is already inside them.

LLM calls fail with SSL errors

  • Set VERIFY_SSL=false in .env (see Corporate TLS section above).

Database connection errors (local-db)

docker compose ps db
docker compose logs db --tail 20
  • APP_DB_PASSWORD not set in .env
  • COMPOSE_PROFILES=local-db missing from .env — the container won't start without it
  • DATABASE_URL set by hand and pointing at localhost — leave it unset for the bundled database; inside Docker the host is db, not localhost
  • Port 5434 already in use on the host
  • Volume permission issue on Linux — set LOCAL_UID/LOCAL_GID in .env to match your host user

Database connection errors (external PostgreSQL)

docker compose logs backend --tail 30
  • Wrong hostname, port, user, or password in DATABASE_URL
  • Missing ?ssl=require — most managed providers enforce SSL
  • Firewall or security group not allowing the Docker host's outbound IP

Deployment questions or issues? Email [email protected]. For the full reference see the upstream deployment guide and the configuration docs on GitHub.