Deployment

Containers

Docker Compose in agency-server runs the full Agency stack.

Core services

  • WSGI (agency_django) — HTTP REST, admin, product SPA shell at /

  • ASGI (agency_asgi) — WebSockets at /channel/

  • Celery — background tasks (workers + beat; Flower optional)

  • Nginx (agency_nginx) — reverse proxy and /static/

Data

  • PostgreSQL — primary database

  • ChromaDB — vector store for corpus embeddings

  • Redis — Celery broker and Channels layer

AI

  • Ollama — local LLM inference (optional GPU)

  • ComfyUI (agency_comfy) — local GPU txt2img (SD 1.5); UI at http://127.0.0.1:8188

  • Voice (agency_voice) — STT (faster-whisper) + TTS (Piper), proxied at /api/voice/

Workflow

Nginx reverse-proxies HTTP and WebSocket traffic to Django (WSGI/ASGI).

digraph AgencyArchitecture { rankdir=TB; node [shape=box]; ranksep=1.5; nodesep=0.75; Client [label="Client"]; subgraph cluster_1 { label="Nginx\nWeb Server"; color="grey"; HTTP1 [label="/*", color="blue"]; HTTP2 [label="/channel/*\n\/stream/*", color="green"]; Static [label="Static Files", color="purple"]; Media [label="Media Files", color="purple"]; } subgraph cluster_2 { label="Django\nFramework"; color="grey"; subgraph cluster_21 { label="Gunicorn\n\nport: 8001"; color="grey"; WSGI [label="WSGI\n\nApplication", color="blue"]; } subgraph cluster_22 { label="Daphne\n\nport: 8002"; color="grey"; ASGI [label="ASGI\n\nApplication", color="green"]; } subgraph cluster_3 { label="Celery"; Work [label="Workers\n\nBackground Tasks", color="red"]; Beat [label="Beat\n\Scheduled Tasks", color="magenta"]; color="grey"; } Collect [label="Collect", color="purple"]; Uploads [label="Uploads", color="magenta"]; } subgraph cluster_4 { label="Services"; color="grey"; Queue [label="Queue\n\nRedis port: 6379", color="yellow"]; DB [label="DB\n\nPostgreSQL port: 5432", color="blue"]; Store [label="Store\n\nChromaDB port: 8000", color="cyan"]; LLM [label="LLM\n\nOllama\nport: 11434", color="orange"]; } Client -> HTTP1 [label="HTTP"]; Client -> HTTP2 [label="WebSockets/HTTP2"]; Client -> Static [label="Assets"]; Client -> Media [label="Media"]; Collect -> Static [label="command", color="purple"]; Uploads -> Media [label="store", color="magenta"]; HTTP1 -> WSGI [label="proxy wsgi:8001", color="blue"]; HTTP2 -> ASGI [label="proxy asgi:8002", color="green"]; WSGI -> DB [label="SQL", color="blue"]; WSGI -> Work [label="tasks", color="red"]; ASGI -> DB [label="SQL", color="blue"]; ASGI -> Store [label="search", color="cyan"]; ASGI -> Work [label="tasks", color="red"]; ASGI -> Queue [label="pool", color="yellow"]; Work -> Queue [label="pool", color="yellow"]; Work -> DB [label="SQL", color="blue"]; Work -> Store [label="search", color="cyan"]; Work -> LLM [label="invoke", color="orange"]; Work -> ASGI [label="results", color="green"]; Beat -> Work [label="calls", color="magenta"]; }

Protocols

  • HTTP/1.1

  • HTTP/2

  • Server-Sent Events (SSE) — log streams

  • WebSockets — see Connections

Boot

docker compose up runs migrate, collectstatic, and seed scripts (superuser, default workspace, Ollama provider). Container names use the agency_ prefix (e.g. agency_django).

Dev commands and container aliases: Server.

Authentication

Agency uses django-allauth for authentication. The product login page is /login/ (served by the SPA shell). Local development defaults to username/password; production uses Google OAuth as the primary login method with password behind a dev toggle.

URL routing

path("accounts/", include("allauth.urls")) is mounted in server.urls before the SPA catch-all so allauth endpoints (/accounts/google/login/, /accounts/google/login/callback/, etc.) are resolved by Django, not the Vue router.

Login UI contract

When GOOGLE_CLIENT_ID is set, the login bootstrap JSON includes oauth.google.enabled = true and login_url = "/accounts/google/login/". The SPA renders a Continuar con Google button that POSTs to /accounts/google/login/ with CSRF. Username/password fields are hidden behind a dev toggle in production.

Google OAuth configuration

To configure Google OAuth for a production host (e.g. agency.nabla.net):

  1. Create OAuth 2.0 credentials in the Google Cloud Console.

  2. Configure the OAuth consent screen and add authorized domains.

  3. Set Authorized JavaScript origins:

    https://agency.nabla.net
    
  4. Set Authorized redirect URIs:

    https://agency.nabla.net/accounts/google/login/callback/
    
  5. Set environment variables (via .env or secrets manager):

    GOOGLE_CLIENT_ID=<your-client-id>.apps.googleusercontent.com
    GOOGLE_CLIENT_SECRET=<your-client-secret>
    

Site domain

allauth builds OAuth callback URLs from django.contrib.sites.Site.domain. If the domain stays at Django’s default example.com, token exchange fails with a redirect-URI mismatch.

boot.py calls ensure_site_domain() at startup. It sets Site.domain from the first non-wildcard host in DJANGO_ALLOWED_HOSTS, or from the explicit DJANGO_SITE_DOMAIN env var if set. Production example:

DJANGO_ALLOWED_HOSTS=agency.nabla.net
# or explicit override:
# DJANGO_SITE_DOMAIN=agency.nabla.net

HTTPS protocol

When DJANGO_SECURE_SSL=true, settings enables:

  • ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'https' — allauth builds https:// callback URLs (required when nginx terminates TLS)

  • SECURE_PROXY_SSL_HEADER, SECURE_SSL_REDIRECT

  • SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE

DJANGO_DEBUG=False
DJANGO_SECURE_SSL=true
DJANGO_ALLOWED_HOSTS=agency.nabla.net
DJANGO_CSRF_TRUSTED_ORIGINS=https://agency.nabla.net

SECRET_KEY stability

OAuth state is stored in the Django session. If WSGI, ASGI, and Celery containers use different (or auto-generated) secret keys, the session cannot be decoded and the callback returns 401.

Production requires a stable DJANGO_SECRET_KEY shared by all containers. If DEBUG=False and the key is missing, startup raises RuntimeError.

# Generate once, share everywhere:
python -c "import secrets; print(secrets.token_urlsafe(64))"
DJANGO_SECRET_KEY=<generated-key>

Adapter logging

LoggingSocialAccountAdapter (server.adapters) logs OAuth errors that allauth would otherwise swallow. Check system.log (allauth logger) when callbacks fail.

See context/django/config.env.production.example for a complete template.

Staff / superuser

Production does not seed the insecure admin/1234 account. Staff access is granted through first-admin promotion or an email allowlist.

First-admin promotion — on a fresh install with zero superusers, the first user to sign in via Google is promoted to is_staff=True and is_superuser=True. The adapter calls maybe_promote_user on every Google login (save_user for new accounts, pre_social_login for existing ones).

Email allowlist — DJANGO_SUPERUSER_EMAILS (comma-separated, case-insensitive) promotes listed users on their next Google login, even when a superuser already exists:

DJANGO_SUPERUSER_EMAILS=alice@example.com,bob@example.com

At boot, promote_allowlisted_users() promotes any existing accounts on the list (for deploys that already have users before the allowlist is configured).

Package helpers — agency.auth.staff provides maybe_promote_user(user), promote_allowlisted_users(), and the promote_superuser management command for manual promotion:

docker compose exec wsgi python manage.py promote_superuser alice@example.com

Dev environment — DJANGO_SUPERUSER_PASSWORD=1234 in config.env seeds admin/1234 for local testing. Omit or leave empty in production.