Skip to content

Deployment

Mohammad-Hasan-Kaman edited this page Oct 20, 2026 · 2 revisions

Deployment

The widget ships as a single Python process — FastAPI behind Uvicorn, with SQLite for storage. There is no external database, no Redis, and no message broker. That is what makes the deployment small enough to run on a modest VPS.


Requirements

Python 3.11+ (the Docker image is python:3.12-slim)
RAM ~300 MB is enough for a single worker
Disk The data/ folder, plus room for the Docker image
Network Outbound HTTPS to the source site, for the crawler

Dependencies are pinned exactly in requirements.txt:

fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
itsdangerous==2.2.0
python-dotenv==1.0.1
requests==2.31.0
beautifulsoup4==4.12.3
APScheduler==3.10.4
pandas==2.2.3
openpyxl==3.1.5
httpx==0.28.1

No PyTorch, no transformer, no model weights — the "AI" is rule-based and runs on the CPU in milliseconds.


Environment variables

app/config.py reads everything from the environment via python-dotenv. Copy .env.example to .env and edit it:

cp .env.example .env
Variable Default What it does
SECRET_KEY "" Signs the session cookies. Must be set in production.
ADMIN_PANEL_PASSWORD "" Fallback password for a tenant admin.
SUPER_ADMIN_USERNAME superadmin The super-admin username.
SUPER_ADMIN_PASSWORD falls back to ADMIN_PANEL_PASSWORD The super-admin password.
CORS_ORIGINS http://localhost:8000 Comma-separated list of allowed origins. Never *.
WIDGET_ORIGIN https://nikravan.org The widget host, used for the embed snippet.
CRAWLER_ENABLED 1 Set to 0 to stop scheduled crawling.
CRAWL_STALE_AFTER_SECONDS 21600 (6 hours) When the panel warns that availability data is old.
COOKIE_SECURE 0 Set to 1 behind HTTPS.

Generating the secret key

python -c "import secrets; print(secrets.token_hex(32))"

The cookie flag

COOKIE_SECURE = os.getenv("COOKIE_SECURE", "0") == "1"

Behind an HTTPS reverse proxy, COOKIE_SECURE=1 makes the panel cookie HTTPS-only — it is never sent over plain HTTP. The default is 0 so that local development over http://localhost still works.


Running it directly

git clone https://github.com/Mohammad-Hasan-Kaman/ai-counseling-widget.git
cd ai-counseling-widget

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

cp .env.example .env
# edit .env

uvicorn app.main:app --host 0.0.0.0 --port 8000

Then:

URL Purpose
/ Product landing page
/widget?key=nk_… The widget, standalone
/demo/{api_key} A tenant's live demo
/admin The admin panel
/api/chat/health Health check → {"status":"ok"}

The database is created and seeded on the first boot. There is no migration step — init_all_dbs() runs every CREATE TABLE IF NOT EXISTS and seeds the Nikravan tenant, the super-admin account, and the consultant roster from the JSON.


Docker

The image

FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY static ./static
RUN mkdir -p data
EXPOSE 8000
HEALTHCHECK --interval=60s --timeout=10s --start-period=20s \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/chat/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

Three details worth noting:

  • A slim base image — no build tools in the final image.
  • A built-in health check that hits /api/chat/health every 60 seconds, with a 20-second grace period for the first boot.
  • --workers 1 — a single worker. This is deliberate: the rate limiter and the scheduler live in process memory, so a second worker would double the rate limit and start a second crawler. Scale with a reverse proxy, not with workers.

Compose

services:
  widget:
    build: .
    container_name: nikravan-widget
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"
    env_file: .env
    environment:
      - CRAWLER_ENABLED=1
    volumes:
      - ./data:/app/data
      - ./.env:/app/.env:ro
  • The port is bound to 127.0.0.1 — the container is not reachable from the internet directly; only the reverse proxy talks to it.
  • data/ is a bind mount, so the databases survive a rebuild.
  • .env is mounted read-only (ro).
docker compose up -d --build
docker compose logs -f widget
curl http://127.0.0.1:8000/api/chat/health     # must return {"status":"ok"}

Nginx

server {
    listen 443 ssl;
    server_name widget.nikravan.org;

    client_max_body_size 12m;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

client_max_body_size 12m is needed because the admin panel uploads the consultant Excel file. The X-Forwarded-For header is what the rate limiter reads the client IP from — without it every request would look like it comes from the proxy.


After the first deploy

  1. Sign in at /admin as the super admin and change both passwords from the Change password page.
  2. The first crawl runs automatically at startup — watch it in the dashboard's Availability crawl section.
  3. Upload the real consultant Excel from the panel so the production roster replaces the seed data.
  4. Update the embed snippets on the tenant sites with the real data-origin="https://widget.nikravan.org".

Operations

Backups

Back up the data/ folder daily — it holds all three .db files. The simplest reliable form is a plain file copy on a schedule:

tar czf /backup/data-$(date +%F).tar.gz -C /opt/ai-counseling-widget data/

Because the databases use WAL mode, a tar of a live directory can capture a half-written WAL. For a consistent copy, stop the container first, or use sqlite3 data/user_records.db ".backup out.db" while it runs.

Updates

git pull && docker compose up -d --build

The startup sequence is idempotent — every CREATE TABLE is IF NOT EXISTS and the JSON roster is only imported into an empty consultants table, so an update never overwrites the data an operator has uploaded.

Log and health

Health endpoint GET /api/chat/health → {"status":"ok"}
Container logs docker compose logs -f widget
Crawl health The crawl_runs table, shown in the panel

Cleanup that happens on its own

Job Schedule What it does
Crawler every 2 hours Refreshes the appointment availability.
GC every 60 minutes Deletes expired sessions, orphan messages, and messages older than 30 days.

The GC never deletes requests — user_requests is the business record.


Security checklist

  • SECRET_KEY is random, secret, and at least 32 bytes
  • COOKIE_SECURE=1 when the site is behind HTTPS
  • CORS_ORIGINS lists only real domains — never *
  • Both admin passwords are strong (8+ characters) and different from each other
  • allowed_domains is set for every tenant in the super-admin panel
  • .env is not tracked by git — it is in .gitignore
  • data/ is not tracked by git either
  • The panel is reachable only over HTTPS

Known limitations

  • --workers 1 is required. The rate limiter is in-process, and the scheduler would run twice with more workers. Horizontal scaling needs a shared store for the limiter and a separate scheduler process.
  • SQLite, not Postgres. It is fine for a single-process deployment with a moderate number of tenants; a busy multi-tenant deployment would want a real database.
  • No Content-Security-Policy header. The frame-embedding control is X-Frame-Options alone, and /widget is deliberately excluded from it.
  • The crawler depends on the source site's HTML. If the page structure changes, the parse silently finds no free slots. The crawl_runs table and the staleness warning are how you notice.

Related pages

Clone this wiki locally