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). .. graphviz:: 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 :doc:`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: :doc:`../development/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**: .. code-block:: text https://agency.nabla.net 4. Set **Authorized redirect URIs**: .. code-block:: text https://agency.nabla.net/accounts/google/login/callback/ 5. Set environment variables (via ``.env`` or secrets manager): .. code-block:: bash GOOGLE_CLIENT_ID=.apps.googleusercontent.com GOOGLE_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: .. code-block:: bash 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`` .. code-block:: bash 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``. .. code-block:: bash # Generate once, share everywhere: python -c "import secrets; print(secrets.token_urlsafe(64))" DJANGO_SECRET_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: .. code-block:: bash 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: .. code-block:: bash 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.