Skip to content

Docker

Beta

The image

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt

# Bake the embedding model in — see below.
ENV HF_HOME=/opt/hf HF_HUB_OFFLINE=1 VENKAI_EMBEDDING_PROVIDER=semantic
RUN HF_HUB_OFFLINE=0 python -c "\
from sentence_transformers import SentenceTransformer; \
SentenceTransformer('all-MiniLM-L6-v2')" && chmod -R a+rX /opt/hf

COPY . ./venkai/
ENV PYTHONUNBUFFERED=1 PYTHONPATH=/app
EXPOSE 8100
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -fsS "http://localhost:8100/api/health" || exit 1
CMD ["uvicorn", "venkai.api.main:app", "--host", "0.0.0.0", "--port", "8100", \
     "--workers", "1", "--proxy-headers"]
cd venkai
docker build -t venkai-api .
docker run -p 8100:8100 --env-file .env venkai-api

Three details that matter

The model is baked in, and HF_HUB_OFFLINE=1 at runtime. Without this the first search after every deploy downloads ~80 MB from Hugging Face: the container needs outbound internet at runtime, the first user pays the latency, and a Hugging Face outage becomes a Venkai outage. Baking it also pins which weights ship.

The image defaults to VENKAI_EMBEDDING_PROVIDER=semantic, unlike the library, which defaults to hashing. The image has the dependency, so it uses it. Override in .env if you want hashing.

--proxy-headers with --workers 1. Proxy headers are trusted because the container is only reachable through Caddy. A single worker is honest about what has been load-tested — scale horizontally rather than raising it blindly.

Files land in /app/venkai/ with PYTHONPATH=/app, so venkai.api.main:app resolves. Copying to /app directly breaks the import.

Development compose

docker-compose.yml — API plus a Caddy serving the static dashboard.

services:
  venkai-api:
    build: { context: ., dockerfile: Dockerfile }
    container_name: venkai-api
    restart: unless-stopped
    # Port 8100 is NOT published. All access goes through Caddy, so the rate
    # limiter and logs see real client IPs instead of a spoofable
    # X-Forwarded-For from a directly reachable port. For local debugging,
    # temporarily add:  ports: ["8100:8100"]
    env_file: [.env]
    volumes: [venkai-data:/data]
    networks: [venkai, external-infra]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8100/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  venkai-dashboard:
    image: caddy:2-alpine
    restart: unless-stopped
    ports: ["8101:80", "8102:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./dashboard:/srv/dashboard:ro
      - caddy-data:/data
      - caddy-config:/config
    networks: [venkai]

volumes: { venkai-data: , caddy-data: , caddy-config: }
networks:
  venkai: { driver: bridge }
  external-infra: { external: true }

external-infra must already exist

It is declared external: true, so compose will not create it:

docker network create external-infra
Without it, docker compose up fails before starting anything.

This file expects Postgres to live outside the stack. Use the production compose if you want the database managed for you.

Production compose

docker-compose.prod.yml — Postgres, a migration job, the API, and Caddy.

flowchart TB
  I["Internet :80 :443"] --> CY["caddy<br/>caddy:2-alpine"]
  CY -->|api.venkai.fr| API["api<br/>venkai-api:local"]
  CY -->|app.venkai.fr| DASH["static dashboard"]
  M["migrate<br/>runs once, then exits"] --> DB
  API --> DB[("db<br/>postgres:16-alpine")]
  DB --> VOL["venkai-db volume"]
Service Image Role
db postgres:16-alpine POSTGRES_USER=venkai, POSTGRES_DB=venkai
migrate venkai-api:local Runs Alembic once, then exits
api venkai-api:local The application
caddy caddy:2-alpine TLS, reverse proxy, static dashboard

The database URL is composed for you:

VENKAI_DATABASE_URL: postgresql://venkai:${POSTGRES_PASSWORD}@db:5432/venkai

so POSTGRES_PASSWORD is set once, in .env, and consumed by both db and api. Set VENKAI_DATABASE_URL yourself only when targeting a database outside the stack (a managed Postgres, for instance).

Required variables fail fast rather than defaulting:

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
VENKAI_JWT_SECRET: ${VENKAI_JWT_SECRET:?set VENKAI_JWT_SECRET in .env}

Compose refuses to start with a clear message instead of booting an instance with an empty signing secret.

cd venkai
cp .env.example .env       # fill in the two required secrets
docker compose -f docker-compose.prod.yml up -d --build

Operations

docker compose logs -f venkai-api          # logs
docker compose ps                          # health status
docker compose exec venkai-api \
  curl -fsS http://localhost:8100/api/health

# rebuild and roll
docker compose -f docker-compose.prod.yml up -d --build

# migrations only
docker compose -f docker-compose.prod.yml run --rm migrate

Known build history

docker build failed at pip resolution for a period: uvicorn was pinned to 0.30.0 while mcp 1.27.1 requires >=0.31.1. The stack was documented as ready before the image had ever been built successfully.

It is fixed. It is recorded here because it is the exact class of failure to check for yourself: build the image before believing a deployment document, including this one.

docker build -t venkai-api . && echo "image builds"