On this pageThe rule that explains most surprises

Configuration reference

Every environment variable SkillNet reads, what it defaults to, and — the part that is easy to get wrong — whether setting it in .env actually changes anything.

The rule that explains most surprises

.env never reaches a container by itself. .dockerignore keeps it out of the image, and no service in any compose file declares env_file:. The API’s pydantic-settings does say env_file=".env", but inside the container there is no such file to read.

So a variable takes effect in Docker only if the service that needs it lists it in its environment: block, as VAR: ${VAR:-default}. Compose reads .env from the repo root at docker compose up time and interpolates it there. That indirection is why a setting can be real in src/config.py, documented, spelled correctly in your .env, and still do nothing.

The Reachable in Docker column below records exactly that, per variable:

Value Meaning
Yes Listed in a service’s environment: block; your .env value wins.
Overlay only Only reaches the container under the named optional compose overlay or profile.
No No compose file passes it. Setting it in .env does nothing under Docker.
Fixed by compose Compose sets a literal value; a .env entry is ignored.

A No variable still works when the API runs outside Docker (uv run uvicorn …), because then pydantic-settings really does read .env. Only the Docker path drops it.


Required

Variable Default What it does Reachable in Docker
SECRET_KEY dev placeholder Signs session cookies and derives the key that encrypts per-org provider credentials. Yes
POSTGRES_PASSWORD none — compose refuses to start Database password. Letters, digits, - and _ only. Yes
LLM_API_KEY empty Provider key for generation, tutoring and evaluation. Empty means courses generate empty. Yes

POSTGRES_PASSWORD is interpolated into postgresql+asyncpg://user:PASSWORD@db:5432/db without escaping. A @, :, /, # or ? splits that URL, and the resulting connection error never mentions the password.

Models and providers

Variable Default What it does Reachable in Docker
LLM_MODEL gpt-4o-mini Main model id in litellm form, provider/model. No prefix means OpenAI. Yes
LLM_BASE_URL https://api.openai.com/v1 Endpoint for OpenAI-compatible providers. Leave empty for the provider litellm already knows. Yes
LLM_GENERATION_MODEL falls back to LLM_MODEL Overrides the model used for course generation only. Yes
LLM_TUTOR_MODEL falls back to LLM_MODEL Overrides the model used by the in-course tutor. Yes
LLM_EVAL_MODEL falls back to LLM_MODEL Overrides the model used to evaluate learner answers. Yes
LLM_RUNTIME_FAST_MODEL falls back to LLM_MODEL Fast tier of the v2 runtime router for dynamic courses. Yes
LLM_RUNTIME_HEAVY_MODEL falls back to LLM_MODEL Heavy tier of the v2 runtime router for dynamic courses. Yes
GEMINI_API_KEY empty Read by litellm for gemini/* model ids. Not a field in src/config.py. Yes
VISION_MODEL empty Multimodal model that describes images inside uploaded PDFs. Empty disables it silently. Yes
EMBEDDING_MODEL text-embedding-3-small Embedding model for RAG. Must return 768 dimensions — see below. Yes
EMBEDDING_BASE_URL https://api.openai.com/v1 Endpoint when embeddings come from a different provider than generation. Yes
EMBEDDING_API_KEY empty Key when embeddings come from a different account than generation. Yes
EMBEDDING_DIMENSIONS 768 Dimensions requested from the provider. Must match the column. Do not set. Yes

The 768-dimension contract

document_chunks.embedding is vector(768), pinned by hand in migration 0008. The ORM declares Vector() with no size on purpose, so the database is the only place that number lives. EMBEDDING_DIMENSIONS does not decide the schema — it tells the provider how many dimensions to return, and it has to agree with the column or every chunk insert fails.

services/embedding_check.py compares the two at startup and reports the result in GET /health, because otherwise the mismatch first appears inside an ingestion except.

Three cases:

  • text-embedding-3-small / -large accept a dimensions parameter and truncate for you. SkillNet sends it, so they return 768. Nothing to do.
  • A model whose native output is already 768 — nomic-embed-text, multilingual-e5-base, paraphrase-multilingual — also fits. Nothing to do.
  • Any other dimension (multilingual-e5-small is 384, e5-large is 1024) needs a migration of the column and a re-ingestion of every document. That is not a .env edit.

Owner account and workspace

Variable Default What it does Reachable in Docker
ADMIN_EMAIL empty Headless bootstrap: creates the owner on first start. Blank selects the /setup wizard. Yes
ADMIN_PASSWORD empty Password for that owner. Ships blank; this repo contains no usable password. Yes
ORG_NAME SkillNet Name of the organization created on first start. Yes
WORKSPACE_MODE organization organization or individual. Read only when the organization is first created. Yes
SETUP_WINDOW_MINUTES 30 Minutes the public /setup endpoint stays open after a boot with no owner. 0 removes the limit. Yes
ONBOARDING_ENABLED true Forces the onboarding wizard for learners without a completed profile. Yes

/setup has to be public — it is how the first account gets created — so until an owner exists, whoever reaches it first becomes the owner. That is fine on localhost and not fine behind a tunnel started before the account. When the window closes, restart the api container for another one, or use the X-Setup-Token printed in the API log at startup.

Serving, sessions and exposure

Variable Default What it does Reachable in Docker
PORT 3000 Host port the web service publishes. Not used by docker-compose.dokploy.yml. Yes
COOKIE_SECURE false base, true in the exposure overlays Marks the session cookie Secure. See the precedence note below. Yes
SESSION_LIFETIME_SECONDS 604800 (7 days) How long a session cookie stays valid. Yes
COOKIE_NAME skillnet_session Name of the session cookie. No
CORS_ORIGINS ["http://localhost:3000"] JSON array of allowed origins. Rarely needed: the bundled nginx makes the SPA same-origin. Yes
FORWARDED_ALLOW_IPS * Proxies uvicorn trusts for X-Forwarded-*. * is safe only because api publishes no port. Yes
ENVIRONMENT production in compose production turns on the SECRET_KEY strength checks. Yes
LOG_LEVEL info uvicorn/application log level. Yes
DEBUG false true exposes Swagger at /api/docs; it is hidden otherwise. Yes
MAX_UPLOAD_SIZE_MB 50 Upload ceiling in the API. nginx caps bodies at a fixed 55m independently. Yes
UPLOAD_DIR ./uploads Where uploads are written. Fixed by compose (/data/uploads)
DATABASE_URL local Postgres SQLAlchemy URL. Fixed by compose (built from POSTGRES_*)
POSTGRES_USER skillnet Database role, used by db and by the URL compose builds. Yes
POSTGRES_DB skillnet Database name, same. Yes
DOMAIN none Public hostname Caddy requests a certificate for. Required by the caddy overlay. Overlay only (caddy.yml)
CADDY_EMAIL none Let’s Encrypt account address. Required — an empty value is a Caddyfile parse error. Overlay only (caddy.yml)
CLOUDFLARE_TUNNEL_TOKEN none Token for a named tunnel from the Cloudflare Zero Trust dashboard. Overlay only (cloudflared.yml)

docker-compose.yml reads ${COOKIE_SECURE:-false}, because the default stack speaks plain HTTP on localhost and a Secure cookie would never be sent back.

The three exposure overlays — docker/compose/caddy.yml, cloudflared.yml and quicktunnel.yml — all read ${COOKIE_SECURE:-true}, because each of them puts real TLS in front.

The :- default only applies when the variable is unset. An active COOKIE_SECURE= line in .env therefore wins over all three overlays, including a false you copied from an example and forgot. That is why .env.example ships the line commented out: leave it that way unless you specifically mean to override an overlay.

Sign in with Google

Variable Default What it does Reachable in Docker
GOOGLE_CLIENT_ID empty OAuth client id. Empty disables the whole feature; /auth/google/* answers 404. Yes
GOOGLE_CLIENT_SECRET empty OAuth client secret. Same: empty disables the feature. Yes
GOOGLE_REDIRECT_URI empty Callback URL, matched verbatim by Google. Empty derives it from the request. Yes
GOOGLE_POST_LOGIN_PATH / Path the browser lands on after a successful sign-in. A path, never a URL. Yes
GOOGLE_LOGIN_ERROR_PATH /login Path for a refused sign-in, carrying a google_error query parameter. Yes

What Google may do, by workspace mode

The credentials turn the feature on. What it is allowed to do comes from the organization’s workspace_mode, not from configuration (src/services/google_oauth.py):

  • organization — Google only signs in people who already have an account created by an administrator. An uninvited address is refused and no account is created. SkillNet scopes data per organization, so open registration would be a side door into a company’s data.
  • individual — Google may also create the account, because in a one-person workspace there is nobody to do the inviting.

In both modes Google must report the address as verified. An unverified one is refused: matching on a claim Google does not stand behind would hand the matching account to whoever typed the address.

Get the credentials at https://console.cloud.google.com/apis/credentials → “Create credentials” → “OAuth client ID” → “Web application”. For the default local stack the redirect URI is http://localhost:3000/api/v1/auth/google/callback; Google compares the whole string, trailing slash included.

Text to speech

Variable Default What it does Reachable in Docker
TTS_PROVIDER disabled openai, elevenlabs, google, azure, or disabled. Yes
TTS_API_KEY empty Key for that provider. Yes
TTS_VOICE empty Voice id. Empty selects the provider default (Azure: es-ES-ElviraNeural). Yes
TTS_LANGUAGE es Synthesis language. Yes
TTS_TIMEOUT_SECONDS 30 Per-request timeout for synthesis. Yes
TTS_AZURE_REGION empty Azure Speech region. Never inferred, so sovereign clouds keep working. Yes
TTS_AZURE_ENDPOINT empty Full Azure synthesis endpoint URL. Also never inferred. Yes
TTS_CACHE_DIR data/tts_cache Content-addressed cache of synthesized audio. Yes (compose points it at the tts_cache volume)

Without a working key the two consumers degrade differently. The mascot’s live voice (POST /api/v1/tts/synthesize) hard-fails with 500 and does not fall back to the offline voice; the frontend swallows it, so the learner simply gets no audio. Podcast generation falls back ElevenLabs → Azure → offline eSpeak NG, so a podcast is always produced, only robotic.

Media is baked at seed time and stored content-addressed: learners hear whatever voice was generated when the seed ran, not a per-user render.

Images and media artifacts

Variable Default What it does Reachable in Docker
OPENROUTER_API_KEY empty Key litellm uses for openrouter/* models, which is the default image engine. Yes
IMAGE_API_KEY empty The image provider’s own key, when the image account is not the text account. Yes
IMAGE_MODEL openrouter/google/gemini-2.5-flash-image Primary image model for posters, infographics and slide art. Yes
IMAGE_FALLBACK_MODEL gpt-image-1 Per-call fallback, billed against LLM_API_KEY. Yes
MEDIA_ASSETS_DIR data/media_assets Where generated mp3/png/mp4 are stored, keyed by content hash. Yes (compose points it at the media_assets volume)
PODCAST_SCRIPT_MODEL empty → LLM_MODEL Model for the podcast script agent. No
PODCAST_DIALOGUE_MODEL eleven_v3 ElevenLabs Text-to-Dialogue model for the two-host voice path. No
PODCAST_VOICE_A ElevenLabs “Sarah” First podcast host voice id. No
PODCAST_VOICE_B ElevenLabs “Antoni” Second podcast host voice id. No
SLIDES_MODEL empty → LLM_MODEL Model for the slide-deck content agent. No
INFOGRAPHIC_MODEL empty → LLM_MODEL Model for the infographic content agent. No
VIDEO_NARRATION_MODEL gpt-4o-mini Model that writes one narration line per slide for video overviews. No

MEDIA_ASSETS_DIR and TTS_CACHE_DIR default to paths relative to the working directory, which is image filesystem. Compose overrides both to /data/... so the named volumes hold them; without that, every --build threw the generated media away.

Without an image key the capability is refused up front with a reason, rather than accepted and failed later.

Provider retries and reasoning models

None of these reach the container. They are listed because they are real settings that work outside Docker, and because the gap is worth knowing before you spend an afternoon on it.

Variable Default What it does Reachable in Docker
LLM_MAX_ATTEMPTS 5 Attempts before a provider call gives up. Read only by src/llm/client.py. No
LLM_RETRY_BASE_SECONDS 4.0 Backoff base, used only when the provider does not say how long to wait. No
LLM_RETRY_MAX_WAIT_SECONDS 90.0 Ceiling on one wait. Above a minute a TPM window has already reset. No
LLM_REASONING_EFFORT low none, low, medium, high. none never sends the parameter. No
LLM_REASONING_TOKEN_HEADROOM 2048 Extra completion budget for a reasoning model, on top of what the call site asked for. No

A reasoning model bills its chain of thought against the same max_tokens as the answer, so without headroom it can spend the whole budget thinking and return empty content, which the runtime reads as an invalid program.

Dynamic courses and the render pipeline

Variable Default What it does Reachable in Docker
ADAPTIVE_EPISODES false Generates each dynamic node as a grounded domain episode instead of the legacy screen formula. Yes
MULTI_AGENT_RENDER false Uses four specialized agents instead of one monolithic generation call. Yes
SEMANTIC_ROUTER false Adds a short classification call in front of the deterministic content-function rules. Yes
RENDER_ALLOW_REACTIVE false Allows OpenUI’s reactive layer. Cost of switching it on: docs/design/openui-adoption.md §3. No
RUNTIME_COMPONENT_SHORTLIST true Exposes only the 3-5 renderer-safe candidates to the generation prompt. No

A course is served by v2 when it has delivery_mode='dynamic' and schema_status='validated'. There is no global flag; src/services/course_delivery.resolve_delivery is the single decision point.

Running on a local model

Used by docker/compose/ollama.yml, which is an overlay rather than a profile because it has to rewrite api’s environment, and a profile cannot do that.

docker compose -f docker-compose.yml -f docker/compose/ollama.yml up -d --build
Variable Default What it does Reachable in Docker
OLLAMA_LLM_MODEL qwen2.5:7b-instruct Chat model pulled by the ollama service and given to api as ollama_chat/…. Overlay only (ollama.yml)
OLLAMA_EMBEDDING_MODEL nomic-embed-text Embedding model, given to api as ollama/…. Outputs 768 dimensions, so no migration. Overlay only (ollama.yml)

The two prefixes differ on purpose. ollama_chat/… and ollama/… are litellm’s native Ollama providers: litellm appends /api/chat or /api/embeddings itself, so the base URL carries no /v1. Mixing a /v1 base with an ollama/ prefix produces a 404.

On CPU this is slow — roughly 185 s to render one lesson screen with a 7B model, measured 2026-07-27. Useful offline; not comfortable for real use.

Optional services

Variable Default What it does Reachable in Docker
A2A_INTERNAL_API_KEY empty Key the A2A server uses to call /ext/v1. Auto-created in the database on startup if set. Yes
A2A_AUTH_KEY empty Bearer token external agents must present. The server refuses to start without it. Profile only (--profile a2a)
A2A_LLM_MODEL gpt-4o-mini Model for the A2A orchestrator. Profile only (--profile a2a)
A2A_PORT 5000 Host port for the A2A server, published on 127.0.0.1 only. Profile only (--profile a2a)
A2A_AGENT_URL http://localhost:5000 URL the agent card advertises to callers. Profile only (--profile a2a)
SKILLNET_MCP_PORT 3001 Host port for the MCP server, published on 127.0.0.1 only. Profile only (--profile mcp)
SKILLNET_MCP_API_KEY empty Default SkillNet key for MCP clients that send no Authorization of their own. Profile only (--profile mcp)
API_FIXTURES_PORT 8001 Host port for the keyless api-fixtures service. Profile only (--profile fixtures)

api-fixtures shares SECRET_KEY and the database with api, so it is not a sandbox — its session cookies are valid against the real API. It is published on 127.0.0.1 for that reason. The bundled nginx proxies to api unconditionally, so the SPA never uses it: to run the whole stack keyless, set LLM_MODEL=fixture/local and EMBEDDING_MODEL=fixture/local instead.

Do not set

These are in src/config.py and in some compose files, and setting them in .env is either inert or a way to break the install. They are deliberately absent from .env.example.

Variable Why not
RENDER_BACKEND Typed Literal["openui"], and the backend registry has exactly one entry. Nothing else is valid.
EMBEDDING_DIMENSIONS Must be 768. Changing it is a migration plus a re-ingestion; compose already defaults it.
LLM_FIXTURE_DIR Path inside the installed package (src/llm/fixture_data) so fixtures ship in the image.
LLM_FIXTURE_MODE replay (default) or record. Developer-only: record calls the real provider and writes pairs to disk.
RUNTIME_SELECTION_STRATEGY Research dial. top5/v1 is the runtime behaviour; the other strategies are forced to shadow.
RUNTIME_SELECTION_EXECUTION Research dial, paired with the above. live is the shipped value.
DATABASE_URL Compose builds it from POSTGRES_*. A .env value is ignored under Docker.
UPLOAD_DIR Compose fixes it to /data/uploads, the mounted volume. A .env value is ignored.
  • RUNNING.md — the five steps to start the project.
  • docker-deployment.md — the services and what each one does.
  • security.md — the threat model behind the defaults above.
  • tuning.md — generation-quality dials, most of which are code, not env vars.