Skip to Content
GuidesDeploy Eneo

Deploy Eneo

Deploy Eneo in production with Docker Compose. This page is the primary deployment guide: it takes you from prerequisites to a verified installation, then links to focused guides for storage, authentication, and AI providers.

This is an example configuration. You MUST customize all values before deployment. Search for your-domain.com, changeme, and CHANGEME and replace with your actual values. Copy-paste without modification will NOT work.

Start here

Your taskGo to
Install a new production instanceQuick start
Upgrade an existing instanceUpdating Eneo
Choose PostgreSQL or object storageChoose Content Storage
Configure OIDC or SSOSingle-Tenant OIDC or Multi-Tenant OIDC Federation
Configure model providersAI Provider Configuration
Add an optional moduleOptional modules
Diagnose a failed deploymentTroubleshooting

You do not need S3-compatible storage to start Eneo. The default deployment keeps bounded durable content in PostgreSQL. Add SeaweedFS, MinIO, or another compatible endpoint only when your capacity and recovery requirements call for it.

Architecture overview

Eneo uses a small service stack. Durable content defaults to bounded PostgreSQL-inline bytes; an optional private object-store byte plane can be enabled when needed.

Eneo System Architecture showing Client Layer, Reverse Proxy (Traefik), Frontend (SvelteKit), Backend API (FastAPI), Database (PostgreSQL with pgvector), Redis Cache/Queue, Worker (ARQ), and AI Providers

ServiceTechnologyPortRole
FrontendSvelteKit3000Web interface, SSR
BackendFastAPI (Python)8000API server, authentication
WorkerARQ-Background processing, document indexing
DatabasePostgreSQL 16 + pgvector5432Data storage, vector search
RedisRedis 76379Cache, task queue
Object contentS3-compatible endpoint8333 privateDurable original and derived bytes (optional)
db-initPython-One-time database initialization
ModulesSeparate containersper moduleOptional module apps on module_net (Compose overlay)

Traefik is provided as an example reverse proxy, not a requirement. You can use nginx, HAProxy, Caddy, or any reverse proxy that supports the routing requirements below.


Prerequisites

Before deploying, ensure you have:

  • Docker and Docker Compose v2+ installed
  • Domain name with DNS pointing to your server
  • SSL certificate (Let’s Encrypt via Traefik, or your own PKI/CA)
  • An API key for at least one AI provider. Providers are added after the first login in Admin → Models (see AI Provider Configuration)

System Requirements

RequirementMinimumRecommended
CPU2 cores4+ cores
RAM4GB8GB+
Disk20GB50GB+
NetworkPorts 80, 443 open-

Quick start

Before running docker compose up:

  • replace every your-domain.com, changeme, and CHANGEME;
  • generate the database password, JWT_SECRET, and URL_SIGNING_KEY;
  • set PUBLIC_ORIGIN in the backend and frontend files;
  • have at least one AI provider API key ready for Admin → Models;
  • create the proxy_tier network;
  • download the complete release.env and release.json bundle from the selected release;
  • leave the optional object-store block commented unless you have chosen and prepared that storage path.

Get deployment files

mkdir eneo-deployment && cd eneo-deployment # Download release.json and release.env from the selected GitHub release into this directory. # Use its exact source revision for the deployment files too. ENEO_SOURCE_REVISION=$(python3 -c 'import json; print(json.load(open("release.json"))["revision"])') curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/docker-compose.yml" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/docker-compose.object-content.yml" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/env_backend.template" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/env_frontend.template" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/env_db.template" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/.env.template" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/setup.py" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/docker-compose.without-tools.yml" # Only if you plan to run optional modules (see "Optional modules" below) curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/docker-compose.modules.yml" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/env_modules.template" curl -fLO "https://raw.githubusercontent.com/eneo-ai/eneo/$ENEO_SOURCE_REVISION/docs/deployment/env_module_ttt.template"

Deployment templates must match the bundle’s source revision. The same rule applies when cloning the repository: check out the revision recorded in release.json before copying the deployment files.

Create environment files

cp env_backend.template env_backend.env cp env_frontend.template env_frontend.env cp env_db.template env_db.env cp .env.template .env python3 setup.py chmod 600 .env env_backend.env env_frontend.env env_db.env

Configure required values

Generate secrets and update environment files:

# Generate JWT_SECRET (backend token signing key) JWT_SECRET=$(openssl rand -hex 32) echo "JWT_SECRET=$JWT_SECRET" # Generate URL_SIGNING_KEY echo "URL_SIGNING_KEY=$(openssl rand -hex 32)" # Generate database password (MUST match in env_backend.env AND env_db.env) echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)"

Edit env_db.env:

  • POSTGRES_PASSWORD - paste the generated value (the template ships POSTGRES_USER=postgres and POSTGRES_DB=eneo; keep them unless you know why you are changing them)

Edit env_backend.env:

  • POSTGRES_PASSWORD - the same value as in env_db.env
  • JWT_SECRET - paste the generated value
  • URL_SIGNING_KEY - paste the generated value
  • PUBLIC_ORIGIN - your external URL (e.g., https://eneo.example.com)

Edit env_frontend.env:

  • JWT_SECRET - listed for parity with the backend; the SvelteKit app does not read it
  • ENEO_BACKEND_URL - the external backend URL used by server-side rendering (e.g., https://eneo.example.com)
  • ENEO_BACKEND_SERVER_URL - the internal Docker URL that SSR requests are rewritten to, skipping the reverse proxy (http://backend:8000). When ENEO_BACKEND_URL is the app’s own origin, as above, SSR requests are plain server-to-server calls without a browser Origin header and do not depend on the CORS allowlist
  • PUBLIC_ENEO_BACKEND_URL - the backend origin used by the browser (e.g., https://eneo.example.com). Do not append /api: the client adds /api/v1/... itself.
  • PUBLIC_ORIGIN - your external URL (e.g., https://eneo.example.com)
  • ORIGIN - same as PUBLIC_ORIGIN

Edit docker-compose.yml:

  • Replace your-domain.com with your actual domain (4 locations)
  • Replace your-email@domain.com with your email for Let’s Encrypt

The default .env needs no object-store values. Eneo uses bounded PostgreSQL-inline content. To enable the optional SeaweedFS profile or an external compatible endpoint, follow Object Content Storage before deployment.

Create Docker network and deploy

# Create external network for Traefik docker network create proxy_tier # Start all services docker compose --env-file .env --env-file release.env up -d # Check status docker compose ps # View logs docker compose logs -f

Verify and login

Navigate to https://your-domain.com

Default credentials: user@example.com / ChangeMePassword1!

Change this password immediately after first login.

Then add your first model provider in Admin → Models (see AI Provider Configuration).


Network Isolation

The Docker Compose stack uses five networks:

NetworkServicesPurpose
proxy_tier (external)Traefik, frontend, backend, workerIngress and outbound access for APIs, OIDC, and crawls
data_net (internal: true)db, redis, backend, worker, db-initData layer without internet egress, isolated from Traefik and frontend
module_net (internal: true)Traefik, backend, optional module containersModules reach only backend:8000; no internet egress
tool_runtime_net (internal: true)tool-runtime, backend, workerBundled tools runtime; no internet, database, or Redis route
object_content_net (internal: true)optional object-content, backend, workerPrivate S3-compatible byte plane when enabled; never routed publicly

PostgreSQL and Redis are only attached to data_net. They are not reachable from frontend, Traefik, or module containers, and they have no outbound internet access.

Existing installations: Older deployment files put every service on proxy_tier. Running docker compose --env-file .env --env-file release.env up -d with the current template recreates containers with the new network membership while keeping PostgreSQL and Redis volumes intact. External backup jobs or admin tools that connected to db:5432 or redis:6379 over proxy_tier must run through docker exec or attach to eneo_data_net.

Verify the isolation after upgrading:

# Should fail: frontend can no longer resolve the database docker exec eneo_frontend getent hosts db # Should succeed docker exec eneo_backend python -c "import socket; socket.getaddrinfo('db', 5432)" curl -fsS https://your-domain.com/version

Environment Configuration

The tables below cover the values you must set or are most likely to change. The templates in docs/deployment/ are the complete reference: every variable is listed there with its shipped value and a comment.

Backend Environment Variables

Infrastructure (required):

The backend builds its PostgreSQL and Redis connection URLs from these variables. There is no DATABASE_URL or REDIS_URL.

VariableTemplate valueDescription
POSTGRES_USERpostgresDatabase user; must match env_db.env
POSTGRES_PASSWORDchangemeChange this; must match env_db.env
POSTGRES_HOSTdbCompose service name
POSTGRES_PORT5432
POSTGRES_DBeneoDatabase name; must match env_db.env
REDIS_HOSTredisCompose service name
REDIS_PORT6379

Redis authentication (optional):

Redis is only reachable on the internal data_net network and runs without a password by default. To require one, define REDIS_PASSWORD in the deployment .env next to docker-compose.yml. The Redis container and the backend, worker and database initialisation services all read that value, so it is set in one place.

# .env REDIS_PASSWORD=<output of: openssl rand -hex 32>

Apply the change with docker compose --env-file .env --env-file release.env up -d. Redis and the backend services restart together; queued jobs and other Redis data stay in the volume. While the password is set, run Redis commands with docker compose exec redis sh -c 'REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli ping'.

VariableDefaultDescription
REDIS_PASSWORDunsetPassword the backend and workers present to Redis. Unset or blank connects without authentication
REDIS_USERNAMEunsetRedis ACL user, for a Redis managed outside the bundled stack. Requires REDIS_PASSWORD

Security (required):

VariableTemplate valueDescription
JWT_SECRETemptyToken signing key (32+ chars). Generate: openssl rand -hex 32
URL_SIGNING_KEYemptyURL signing key. Generate: openssl rand -hex 32
PUBLIC_ORIGINemptyExternal URL, e.g. https://eneo.example.com. Used to build the OIDC redirect URI {PUBLIC_ORIGIN}/login/callback
FILE_REFERENCE_BASE_URLhttp://backend:8000 in the reference docker-compose.yml; unset elsewhere (falls back to PUBLIC_ORIGIN)Base URL written into the signed file links handed to MCP tools and the bundled tool runtime. With the reference Compose file, set it in the deployment .env, not in env_backend.env. See Signed file references.
API_PREFIX/api/v1Prefix for all API routes
API_KEY_HEADER_NAMEX-API-KeyHeader that carries API keys (including ENEO_SUPER_API_KEY)

Default User Setup:

These values are pre-filled in the template and read by db-init on the first run. If the tenant or user already exists, db-init skips creation.

VariableTemplate valueDescription
DEFAULT_TENANT_NAMEExampleTenantName for the initial tenant
DEFAULT_TENANT_QUOTA_LIMIT10737418240Storage quota for the initial tenant (bytes)
DEFAULT_USER_NAMEExampleUserName for the initial admin user
DEFAULT_USER_EMAILuser@example.comEmail for the initial admin user
DEFAULT_USER_PASSWORDChangeMePassword1!Password for the initial admin user (change after first login)
USING_ACCESS_MANAGEMENTtrueMounts the roles API used by Admin → Users

OIDC Authentication (Enterprise SSO):

Glossary: IdP = Identity Provider (Entra ID, MobilityGuard, Auth0). OIDC = OpenID Connect protocol.

Choose one mode:

  • Single-Tenant via env vars (FEDERATION_ENABLED=false): Configure OIDC_DISCOVERY_ENDPOINT, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET in env_backend.env. Requires a restart to change settings. Simplest for single-organization deployments.
  • Federation via API (FEDERATION_ENABLED=true): Configure IdP via sysadmin API. Changes take effect immediately (no restart). Use for multi-tenant deployments OR single-tenant with dynamic configuration.

FEDERATION_PER_TENANT_ENABLED is a deprecated alias of FEDERATION_ENABLED; the backend logs a warning when it is used.

VariableTemplate valueDescription
OIDC_DISCOVERY_ENDPOINTemptyIdP discovery URL (.../.well-known/openid-configuration) for single-tenant mode
OIDC_CLIENT_IDemptyOIDC client ID
OIDC_CLIENT_SECRETemptyOIDC client secret
OIDC_TENANT_IDemptyEneo tenant UUID required by the global OIDC/MobilityGuard login endpoint
OIDC_ALLOWED_DOMAINS[]Global OIDC email-domain restriction (JSON array); required for JIT account creation
FEDERATION_ENABLEDfalseEnable per-tenant IdP configuration via API
ENCRYPTION_KEYempty44-char base64 Fernet key (required for credentials & federation)
ENEO_SUPER_API_KEYemptySuper admin API access (sent as X-API-Key)

Register https://your-domain.com/login/callback as the redirect URI at your IdP. OIDC state and redirect safety controls (OIDC_STATE_TTL_SECONDS, OIDC_REDIRECT_GRACE_PERIOD_SECONDS, STRICT_OIDC_REDIRECT_VALIDATION, OIDC_CLOCK_LEEWAY_SECONDS) are documented in the template.

Generating ENCRYPTION_KEY:

ENCRYPTION_KEY is required when enabling TENANT_CREDENTIALS_ENABLED=true or FEDERATION_ENABLED=true. The backend refuses to start without it.

# Development (with uv) uv run python -m eneo.cli.generate_encryption_key # Production (Docker) docker compose run --rm backend python -m eneo.cli.generate_encryption_key # Alternative (Python) python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'

Backup your ENCRYPTION_KEY securely. Without it, encrypted credentials and federation secrets cannot be decrypted. If lost, you must re-enter all tenant API keys and reconfigure all IdP settings.

Upload policy:

Upload limits are not environment variables anymore. Administrators with the Storage permission set byte limits and new-write placement in Admin → File storage; those changes take effect without restarting backend or worker. The four UPLOAD_*/TRANSCRIPTION_MAX_FILE_SIZE lines in the template seed that policy once and are otherwise ignored.

Operators keep OBJECT_CONTENT_INLINE_MAXIMUM_BYTES (template: 10485760) as a PostgreSQL, WAL, backup, and process safety ceiling. For an inline session upload, the effective limit is the smaller value; the admin page shows the configured value, effective value, and constraining source.

Crawler Settings:

VariableDefaultDescription
CRAWL_MAX_LENGTH36000Max crawl duration in seconds (~10 hours)
CLOSESPIDER_ITEMCOUNT20000Max pages and linked files per crawl
CRAWL_JOB_CONCURRENCY_LIMITWORKER_MAX_JOBSCluster-wide crawl admission limit across crawler replicas
CRAWL_HEARTBEAT_INTERVAL_SECONDS300PostgreSQL lease renewal interval
CRAWLER_BLOCK_PRIVATE_NETWORKSfalseAlso refuse private (intranet) ranges; loopback, link-local and metadata addresses are always refused

Crawler network access. The crawler only fetches http(s) URLs. Loopback, link-local (including the cloud metadata address), unspecified and multicast destinations are always refused at connection time, including after redirects. Private (intranet) ranges are allowed by default so intranet sites can be indexed; set CRAWLER_BLOCK_PRIVATE_NETWORKS=true on backend and worker to refuse them as well. The worker’s HTTP_PROXY/HTTPS_PROXY are not used for crawling.

Background Worker:

VariableDefaultDescription
WORKER_MAX_JOBS15Max concurrent jobs per worker process; general and crawler services apply it independently

Each API or worker process owns its own database pool. Budget PostgreSQL max_connections for the sum of every API, general-worker, and crawler-worker process pool, plus operational headroom. Tune WORKER_MAX_JOBS per worker process for its CPU, memory, and database-active workload.

Dynamic Credential Management:

VariableDefaultDescription
TENANT_CREDENTIALS_ENABLEDfalseEnable the sysadmin credentials API (Fernet encrypted)

When TENANT_CREDENTIALS_ENABLED=true, you can add, update, or rotate AI provider API keys per tenant via the Credentials API without restarting the backend. Credentials are encrypted with Fernet (AES-128-CBC + HMAC). Requires ENCRYPTION_KEY to be set; the endpoints answer 404 while the flag is false.

Outbound Provider Headers:

VariableDefaultDescription
OUTBOUND_HEADERS_ALLOWED_DESTINATIONS[]JSON list of endpoint URLs that configured outbound headers may be sent to, e.g. ["https://gateway.internal/v1"]. Empty allows any explicitly configured provider endpoint. Set it on both the backend and the worker, which sends headers for uploads and crawls (the shipped compose file gives both the same env file). An invalid entry stops either at startup. Recommended for multi-tenant deployments. See Outbound headers.

Integrations:

Setting up SharePoint? Webhook subscriptions cannot be created until SHAREPOINT_WEBHOOK_CLIENT_STATE is set; Microsoft echoes it back as clientState and Eneo rejects notifications that do not match. See the SharePoint Integration Guide for complete setup instructions including Azure AD app registration.


Signed file references

When an assistant may open attached files itself (the “Låt assistenten öppna bifogade filer själv” switch, inline_file_text=false), or when a tool edits an attached or generated image, the model receives each file as a signed link instead of its content:

{FILE_REFERENCE_BASE_URL}/api/v1/files/{file_id}/original/download/?token=...

Eneo’s built-in file tool, the bundled tool runtime and external MCP servers receive the same link. The built-in file tool verifies the token locally and never makes an HTTP request. The tool runtime and external MCP servers redeem the link over HTTP, so the Eneo backend must be reachable at FILE_REFERENCE_BASE_URL from the network where they run. This is independent of where the file bytes live: originals stored in PostgreSQL and originals stored in object storage are served through the same endpoint.

The download endpoint accepts no session cookie or API key. The signed token is the only credential: it names the file and tenant, is signed with URL_SIGNING_KEY, and expires after FILE_REFERENCE_URL_EXPIRY_SECONDS (default 15 minutes, at most one hour). Anyone holding an unexpired link can download the file. Treat every MCP server that receives file links as a recipient of the file contents, and enable such servers only where that data flow is acceptable.

Three controls limit the exposure of a link:

  • A tool call may only carry links to the files of its own conversation: the attachments and generated files Eneo minted links for in that request. A link to any other file in the arguments, even a valid one pasted in by a user or returned by another tool, stops the call before the MCP server is contacted.
  • Every redemption is audited as file_original_downloaded under File operations, with the caller’s address and user agent, next to the file_original_download_link_created entry written when the link was minted.
  • The token is stripped from tool-call arguments and results before they are stored, shown in the chat, or replayed to the model. Only the tool that was called ever holds the live link. Deleting the file revokes every outstanding link for it.

Choose FILE_REFERENCE_BASE_URL by where the MCP servers run:

MCP servers runFILE_REFERENCE_BASE_URLNotes
Nowhere, or only Eneo’s built-in toolskeep the reference default http://backend:8000The bundled tool runtime downloads attachments for file analysis from the backend over the internal tool_runtime_net. Without the reference Compose file, an unset value uses PUBLIC_ORIGIN, which the built-in file tool never contacts.
On the same private network as the backendan internal address the servers can resolve, e.g. http://eneo-backend:8000 or https://eneo.intra.example.sePlain http and internal hostnames are allowed. The address must resolve from the MCP servers’ network and from the bundled tool runtime; browsers never see it. TOOL_RUNTIME_FILE_ORIGINS follows the value.
On the internet, outside the private networkthe public origin, e.g. https://eneo.example.comThe backend must be reachable from the internet on the /api/v1/files/*/original/download/ route. If the deployment is otherwise private, this is the one route that needs to be exposed, and only signed requests succeed on it. Eneo uses one base URL for every recipient, and the bundled tool runtime has no internet access, so its file analysis cannot download from a public origin.

On a private network with no route from the MCP server to the backend, the tool call fails. The model is told the link is valid, so it reports that the tool cannot reach the file rather than asking the user to upload it again. The fix is always network reachability or a corrected base URL, never a re-upload.

Object Content Storage

When object-store rows exist, PostgreSQL and that endpoint form one recovery unit. Inline-only bytes are already part of PostgreSQL backups.

The reference stack defaults to bounded PostgreSQL-inline content. An optional Compose profile runs private, Eneo-built SeaweedFS. Deployments that need an existing storage platform or multi-node availability can instead use another compatible endpoint, including MinIO. Both object-store paths use the same contract and fail closed when configuration or the database/bucket binding does not match.

OwnerResponsibility
Administrator with Storage permissionChoose one deployment-wide target for eligible new File, Icon, and knowledge-original writes and set business upload limits in Admin > File storage
OperatorRun PostgreSQL and any optional compatible endpoint; own credentials, TLS, capacity, backups, and OBJECT_CONTENT_INLINE_MAXIMUM_BYTES

Policy changes take effect without a backend or worker restart. They affect eligible new writes only; existing content stays in its recorded backend until an explicit verified migration moves it. The product has one provider-neutral object-store path, not vendor-specific storage modes. Admin > File storage also compares Eneo’s recorded file-content size with the entire PostgreSQL database on disk. These figures overlap: inline file content is part of both totals, and the PostgreSQL figure also includes searchable text, pgvector embeddings, tables, indexes, and internal storage. The page does not report remaining host or bucket capacity. See What do the storage-usage figures mean? for the exact boundaries.

The same page presents current status before configuration: the active target, object-store health, file-content distribution, and migration state appear first. PostgreSQL allocation and detailed effective limits remain available as technical details instead of competing with routine storage decisions.

The page also shows the portable multipart envelope when it constrains object-store session uploads. FastAPI/Starlette multipart parsing happens before route admission and may use temporary disk. Eneo rejects an oversized File or Icon before its own capture/spool or any storage mutation. Operators must use ingress/request-body limits and configure and monitor temporary-disk capacity to protect that earlier parsing boundary.

Follow Object Content Storage for the image digest, credentials, MinIO policy, TLS, readiness, backup, and troubleshooting steps. See Object Content Architecture for the control-plane boundary, lifecycle, reconciliation, and image supply chain.


Optional modules

Modules are optional web applications (for example speech-to-text) that run next to Eneo on their own domain. A module container joins only module_net, calls the backend at http://backend:8000 with a scoped sk_ service key, and can never reach PostgreSQL or Redis. Users log in through their Eneo session; modules have no IdP configuration of their own.

Modules ship as a Compose overlay that is inert until you enable a profile:

cp env_modules.template env_modules.env cp env_module_ttt.template env_module_ttt.env # In .env, uncomment and set ENEO_DOMAIN, MODULE_TTT_DOMAIN and MODULE_STT_VERSION docker compose -f docker-compose.yml -f docker-compose.modules.yml \ --profile speech-to-text up -d
FilePurpose
docker-compose.modules.ymlThe overlay. Every module sits behind a profile (speech-to-text, or all-modules) and waits for the backend /api/healthz check
env_modules.templateDefaults shared by all module containers (LOG_LEVEL, NODE_ENV, ENEO_API_KEY_HEADER_NAME)
env_module_ttt.templatePer-module secrets for the speech-to-text module (TAL_TILL_TEXT_FLOW_ID, ENEO_API_KEY, SESSION_SECRET)
MODULES.mdOperator guide: install the module in Admin → Modules, mint the service key, smoke test, rotate keys, uninstall

The module domain must point at the same Traefik ingress; the overlay routes it over eneo_module_net. See Module Authentication for the login handoff, ticket exchange, and session refresh contract, and Module administration below for the admin-side prerequisites.


Reverse Proxy Requirements

Any reverse proxy (Traefik, nginx, HAProxy, Caddy) must meet these requirements:

Routing:

PathTargetDescription
/*Frontend (port 3000)All requests except the backend paths below
/api/*Backend (port 8000)API endpoints (/api/v1/...) and health probes
/scim/*Backend (port 8000)SCIM 2.0 provisioning (mounted at /scim/v2; see SCIM)
/docsBackend (port 8000)OpenAPI documentation
/openapi.jsonBackend (port 8000)OpenAPI spec
/versionBackend (port 8000)Version endpoint

These are the exact prefixes the shipped Traefik labels route to the backend.

Headers:

  • X-Forwarded-Proto - original protocol (https)
  • X-Forwarded-For - client IP address
  • Host - original host header

Requirements:

  • WebSocket support (for real-time features)
  • TLS termination with valid certificate

Using nginx Instead of Traefik

upstream frontend { server frontend:3000; } upstream backend { server backend:8000; } server { listen 443 ssl http2; server_name eneo.example.com; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; # API routes to backend location /api/ { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket support proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } # SCIM provisioning (identity provider calls /scim/v2/...) location /scim/ { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /docs { proxy_pass http://backend; proxy_set_header Host $host; } location /openapi.json { proxy_pass http://backend; } location /version { proxy_pass http://backend; } # Everything else to frontend location / { proxy_pass http://frontend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

API Key Hierarchy

Eneo uses environment and user/service credentials for separate tasks.

KeySourcePurpose
Super API KeyENEO_SUPER_API_KEYSysadmin operations: tenants, users, credentials, crawler settings
User/API keyGenerated in EneoOrganization operations allowed by the user’s role and key scope

Both are sent in the X-API-Key header (configurable with API_KEY_HEADER_NAME).

Service Account Pattern: We recommend creating a dedicated admin account (service account) for automated user provisioning via the admin API endpoints, rather than using personal accounts.

Credentials API

Manage AI provider credentials per tenant using the sysadmin API.

Prerequisite: Set TENANT_CREDENTIALS_ENABLED=true and ENCRYPTION_KEY in backend env before using these endpoints; they return 404 otherwise. See Dynamic Credential Management in the Backend configuration.

LiteLLM Architecture showing credential flow from Sysadmin API to encrypted storage to AI providers

curl -X PUT "https://eneo.example.com/api/v1/sysadmin/tenants/{tenant_id}/credentials/{provider}" \ -H "X-API-Key: your-super-api-key" \ -H "Content-Type: application/json" \ -d '{"api_key": "sk-..."}'

Supported {provider} values: openai, anthropic, azure, mistral, ovhcloud, gemini, cohere

Credentials are encrypted with Fernet (AES-128-CBC + HMAC). Backup your ENCRYPTION_KEY - if lost, all credentials must be re-entered.

Crawler Settings API

Configure crawling behavior per tenant. From 2.3 the endpoint rejects unknown keys with 422, including the settings of the retired crawl feeder and tenant semaphore (tenant_worker_concurrency_limit, crawl_feeder_* and similar). Values stored earlier for those keys are ignored.

curl -X PUT "https://eneo.example.com/api/v1/sysadmin/tenants/{tenant_id}/crawler-settings" \ -H "X-API-Key: your-super-api-key" \ -H "Content-Type: application/json" \ -d '{"closespider_itemcount": 20000, "crawl_heartbeat_interval_seconds": 300}'

Enabling Features

After deployment, some features require additional configuration in the Admin panel.

Crawler & Document Upload

Document uploads require the general worker. Web crawls require both the general worker and crawler worker. Enable at least one embedding model in Admin → Models → Embeddings. See how crawling works.

When applying source-URL migration 202609071200, stop the API and both workers, then restart all publishers on the same version. Old publishers identify website documents by title; the new version uses their source URL. The migration preserves metadata and citation IDs and reports ambiguous legacy URLs for review.

Apps (Voice/Audio Features)

Can’t create Apps? This feature requires a transcription model. Go to Admin → Models → Transcription models tab and enable a model like Whisper.

Sysadmin API Access

To use system administration endpoints (tenants, credentials, crawler settings), set the API key in env_backend.env:

ENEO_SUPER_API_KEY=your-secure-api-key

Module administration

Configure modules in Admin → Modules with an ordinary signed-in administrator holding the modules permission. The organization is derived from the session; module administration does not require a separate environment key or a tenant ID. The container side is covered in Optional modules.


Troubleshooting

Common Issues

SymptomLikely CauseFix
Can’t login with default credentialsDEFAULT_* vars commented or db-init didn’t runUncomment vars in env_backend.env, check docker logs eneo_db_init
Can’t see Users page in AdminUSING_ACCESS_MANAGEMENT=false (default is true)Set to true, restart backend
Frontend shows wrong API URLPUBLIC_ENEO_BACKEND_URL incorrectSet to the external origin (not localhost in prod)
Browser requests hit /api/api/v1/…PUBLIC_ENEO_BACKEND_URL ends with /apiUse the bare origin, e.g. https://eneo.example.com
Large file uploads failAdmin policy or applicable operator ceiling is lowerReview configured, effective, and source values in Admin > File storage
“Failed to decrypt credential”ENCRYPTION_KEY missing or changedSet correct key or re-enter credentials
Credentials API returns 404TENANT_CREDENTIALS_ENABLED=falseSet to true, requires ENCRYPTION_KEY
OIDC redirect failsPUBLIC_ORIGIN mismatchMust match in backend, frontend, AND the IdP redirect URI ({PUBLIC_ORIGIN}/login/callback)
Crawls not processingCrawler worker unavailable or execution leases expiredCheck /api/healthz/crawler; an increasing oldest_active_age_seconds or nonzero expired_leases identifies stalled lifecycle work
Crawls/uploads not workingWorker not running or no embedding modelCheck both workers are running, enable embedding model in Admin → Models
Can’t create AppsNo transcription model enabledAdmin → Models → Transcription models tab, enable Whisper
JWT token invalidBackend JWT_SECRET changed since the token was issuedTokens signed with the old key are rejected; sign in again
Worker jobs stuck or slowWorker overloadedReduce WORKER_MAX_JOBS or increase resources
ECONNREFUSED 127.0.0.1:8000Frontend using localhost instead of Docker serviceSet ENEO_BACKEND_SERVER_URL=http://backend:8000 (use service name, not localhost)
HTTP to HTTPS redirect brokenMissing Traefik labelEnsure traefik.enable=true label on traefik service
502 Bad GatewayDomain not replaced in docker-compose.ymlReplace your-domain.com in all 4 locations
SCIM provisioning returns 404Reverse proxy does not forward /scimRoute /scim/* to the backend (see Reverse Proxy Requirements)

Database Shared Memory Errors

Symptom: some requests fail with HTTP 500, and the database log shows could not resize shared memory segment ... No space left on device. The disk is not full: PostgreSQL ran out of /dev/shm, a small memory area inside the container. Docker and Podman limit it to 64 MB unless the service sets shm_size.

PostgreSQL uses it for queries it runs in parallel, and each database connection that has run such a query keeps a small part of it for as long as the connection lives. With many pooled connections and large queries, 64 MB is too little.

Fix: set shm_size: 1gb on the db service, as the reference docker-compose.yml does, and recreate the container:

docker compose up -d db

The value is an upper limit, not memory reserved up front. Recreating the container briefly interrupts Eneo; the data stays in its volume. Set stop_grace_period: 2m on the service too, as the reference file does: with the default 10 seconds, a busy database can be killed before its shutdown checkpoint finishes and then needs crash recovery when it starts.

Until you can recreate the container, stop PostgreSQL from running queries in parallel. This takes effect without a restart and makes large queries slower:

docker compose exec db psql -U postgres -d eneo -c "ALTER SYSTEM SET max_parallel_workers_per_gather = 0;" docker compose exec db psql -U postgres -d eneo -c "SELECT pg_reload_conf();"

If the errors continue, restart the backend so that its database connections reconnect with the new setting.

Undo it after raising shm_size:

docker compose exec db psql -U postgres -d eneo -c "ALTER SYSTEM RESET max_parallel_workers_per_gather;" docker compose exec db psql -U postgres -d eneo -c "SELECT pg_reload_conf();"

Debug Mode

Enable detailed logging for troubleshooting:

# In env_backend.env LOGLEVEL=DEBUG

Then restart and view logs:

docker compose restart backend docker compose logs -f backend

Service Health Checks

The backend is not published on a host port in the reference stack, so run these through your public domain (or from inside a container).

# Check all services are running docker compose ps # Liveness: the API process answers curl -fsS https://your-domain.com/api/livez # Health: backend, worker heartbeat, and object-content readiness (503 if the worker is unhealthy) curl -fsS https://your-domain.com/api/healthz # Readiness alias of /api/healthz on current images (older released images only have /api/healthz) curl -fsS https://your-domain.com/api/readyz # Detailed crawler diagnostics (requires the deployment's super API key) curl -fsS https://your-domain.com/api/healthz/crawler \ -H "X-API-Key: $ENEO_SUPER_API_KEY" # Running backend version curl -fsS https://your-domain.com/version # Check database docker compose exec db psql -U postgres -d eneo -c "SELECT version()" # Check Redis docker compose exec redis redis-cli ping # View db-init logs (for first-run issues) docker logs eneo_db_init

Container Logs

# All services docker compose logs -f # Specific service docker compose logs -f backend # Last 100 lines docker compose logs --tail=100 backend worker

Maintenance

One version for the application

The standard stack includes the isolated tools runtime. Frontend, backend and runtime share one version and source revision; workers use the backend image. Download release.env and release.json from the selected release. The bundle pins all application images by digest and is published only after the combined image checks pass.

Run python3 setup.py to generate the missing runtime credential without changing existing settings or printing secrets. Use both environment files:

docker compose --env-file .env --env-file release.env up -d

Use the same environment-file arguments for later Compose commands. Commands that do not show those arguments need them too. To avoid repeating them, run export COMPOSE_ENV_FILES=.env,release.env once in the shell (Docker Compose 2.24 or later). Without a bundle, python3 setup.py --version X.Y.Z selects one explicit version. There are no default application latest tags.

For upgrades, replace the complete bundle together. For rollback, restore the previous bundle and observe the release’s database rollback requirements. Do not independently update the tools image. Remove old runtime image pins and the optional runtime overlay/profile; the old overlay is a compatibility shim for one release. Existing capability settings and external providers are kept.

The Admin > Tools status panel reports connectivity, credentials, version, confinement and file-origin issues. A version mismatch warns while calls remain allowed. Installing the runtime does not enable capabilities. To omit it, add -f docker-compose.yml -f docker-compose.without-tools.yml; stop any existing runtime container first. Ordinary chat and backend readiness remain independent.

Backups

Take one paired recovery point when object-store rows exist. Inline-only control and bytes are both included in the PostgreSQL backup.

Database backup with pg_dump (recommended):

# Create timestamped database backup docker compose exec -T db pg_dump -U postgres eneo | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz # Restore from backup (if needed) gunzip -c backup_20250108.sql.gz | docker compose exec -T db psql -U postgres eneo

Volume backup (for complete recovery):

# Stop services first docker compose down # Backup PostgreSQL data volume docker run --rm -v eneo_eneo_postgres_data:/data -v $(pwd):/backup ubuntu \ tar czf /backup/postgres_volume_$(date +%Y%m%d).tar.gz /data # Backup Redis data volume (optional) docker run --rm -v eneo_eneo_redis_data:/data -v $(pwd):/backup ubuntu \ tar czf /backup/redis_volume_$(date +%Y%m%d).tar.gz /data

If object-store rows exist, also archive eneo_eneo_object_content_data while backend, worker, and the object-content service are stopped, or take the matching versioned snapshot in your external provider. Follow the object-content backup and restore procedure, including checksum and skew-recovery checks.

Updating Eneo

Select a complete release bundle

Replace release.env and release.json together with the new release bundle. Keep the old bundle for rollback. Application image tags have no v prefix.

Rename environment variables that are no longer read

From 2.2, db-init, the backend, the worker and the web app refuse to start while a renamed variable is set without its replacement, and the web app refuses to start without ENEO_BACKEND_URL. Before upgrading to 2.2, follow Upgrading to 2.2.0.

Review network changes

If your current deployment predates the isolated-network template, recreating the containers changes their network membership. External backup jobs or admin tools that connected to db:5432 or redis:6379 over proxy_tier must run through docker exec or attach to eneo_data_net.

Before the first recreate, choose the Compose invocation that matches the deployment and keep it in both the deployment and rollback runbooks. Use plain docker compose for PostgreSQL-inline content or an external S3-compatible endpoint. If the retained .env points to http://object-content:8333, use the bundled-store overlay and profile on every command.

Drain work and stop old writers

For the release that introduces durable knowledge originals and staged legacy File/Icon adoption, do not use a rolling docker compose up. Stop all backend replicas, let the old worker drain its executable knowledge jobs, then stop all old workers before starting db-init. The schema expansion installs a database write fence because old File/Icon code still changes legacy payload columns.

Follow the File and Icon storage upgrade checklist. Keep the API closed until the new migration and same-release worker are ready.

For the upgrade to 2.3, also stop every backend and worker replica before db-init and start the new versions together. The new crawler’s lifecycle, its migrations and the old Scrapy worker cannot run side by side. Returning to 2.2 requires restoring the backup taken in the next step.

Take the stopped-writer recovery point

After the drain is verified and every old writer is stopped, create the database backup:

docker compose exec -T db pg_dump -U postgres eneo | gzip > backup_$(date +%Y%m%d).sql.gz

If object-store rows exist, take the matching endpoint snapshot while writers remain stopped and label both artifacts as one recovery point. Inline-only content needs no second backup artifact.

Pull and deploy

Run these commands with the new release’s docker-compose.yml in place. crawler-worker exists from 2.3, so Compose rejects it with the 2.2 file; with the 2.3 file and no running container, stopping it does nothing.

For PostgreSQL-inline content or an external endpoint:

docker compose --env-file .env --env-file release.env stop backend worker crawler-worker docker compose --env-file .env --env-file release.env pull docker compose --env-file .env --env-file release.env run --rm db-init docker compose --env-file .env --env-file release.env up -d docker compose --env-file .env --env-file release.env ps

For the bundled store:

docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ config --quiet docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ stop backend worker crawler-worker docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ pull docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ run --rm db-init docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ up -d docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ ps

When any object-store endpoint is configured, verify its explicit readiness code. HTTP 200 alone can also represent an intentionally degraded object store while inline content remains available:

curl -fsS https://eneo.example.eu/api/readyz \ | jq -e '.detail.object_content.code == "ready"'

Check online File and Icon adoption

db-init runs two consecutive revisions for this change. The first installs the schema and write fence and commits. The second inventories legacy File/Icon variants in resumable, idempotent groups without retaining the first revision’s exclusive table locks. Neither revision copies payload bytes. The new worker adopts them online in bounded batches. Follow its state after the deployment:

docker compose logs --since=30m worker \ | grep "File/Icon legacy backfill"
  • active needs no operator action beyond capacity and application monitoring. Confirm complete with the durable campaign-state query in the storage guide; an empty installation may emit no completion log.
  • waiting_for_capacity means Eneo remains available, but the estimated inline copy exceeds the automatic threshold. Confirm payload, WAL, replica, backup, and safety headroom before setting the reported acknowledgement.
  • waiting_for_object_store means this release cannot adopt legacy bytes directly to the selected object store. Selecting PostgreSQL inline changes the deployment-wide target for eligible new writes. Keep it selected until adoption is complete, then reselect object storage and queue verified moves, or wait for a compatible release.
  • halted means the destination changed, or an invalid, oversized, or retry-exhausted item needs action. Fix the logged cause before increasing the resume revision. The worker retries older failures in bounded scheduled batches; a repeated failure waits for the next higher revision.

Do not edit the ledger or remove the database trigger. Existing legacy content remains readable while the worker waits or is stopped. See Upgrade File & Icon Storage for capacity examples, realistic duration, state queries, pause behavior, rollback, and cleanup.

Release search data from earlier versions

Upgrading from 2.2 or from 2.3.0-rc.3 or earlier: those releases kept the chunks and embeddings of every replaced document version. They count toward the storage quota and are never searched. Once the new version runs, remove them once with Free Space Held by Earlier Versions. Eneo stays available while it runs, and it can be stopped and run again.

If you maintain your own Compose file, also give the db service shm_size: 1gb and stop_grace_period: 2m, as the reference file does; see Database Shared Memory Errors.

Clean up old images

docker image prune -a

Rollback: Revert pinned version tags, stop writes, and restore the paired backup. PostgreSQL-inline and external-endpoint deployments use plain docker compose. A bundled-store deployment must use the overlay and profile for stop, restore, and up; otherwise the store and its private network are omitted.

docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ stop backend worker crawler-worker object-content # Restore the matching PostgreSQL dump and object-store snapshot as described # in the linked procedure, then start the pinned versions: docker compose \ --profile object-content \ -f docker-compose.yml \ -f docker-compose.object-content.yml \ up -d curl -fsS https://eneo.example.eu/api/readyz \ | jq -e '.detail.object_content.code == "ready"'

Restore the matching object-store snapshot before reopening traffic. See Back up and restore for skew recovery.

Scaling

The reference docker-compose.yml sets a fixed container_name on every service, so docker compose up --scale ... is refused. To run more than one backend or worker replica, define additional services without container_name (or run the images under an orchestrator) and place the backend replicas behind your load balancer. Additional workers are safe. Crawls run only on crawler-worker services, each with its own /tmp. WORKER_MAX_JOBS applies per process, while CRAWL_JOB_CONCURRENCY_LIMIT and CRAWL_JOB_TENANT_CONCURRENCY_LIMIT cap running crawls across all crawler workers. Set CRAWL_JOB_TENANT_CONCURRENCY_LIMIT to the same value on the backend and every worker.


Run the Next beta alongside the existing app

The existing Svelte frontend remains ghcr.io/eneo-ai/eneo-frontend. The optional Next frontend uses ghcr.io/eneo-ai/eneo-web-next, published and pinned in the same release bundle (release.env sets ENEO_WEB_NEXT_IMAGE). Deploy both against one backend from one bundle. They share accounts, permissions, conversations, assistants, files, workers and database changes. Beta writes are real writes to the same data; this is not a separate sandbox.

Use a separate HTTPS hostname, for example beta.example.com, rather than a path prefix. Keep the existing app and backend PUBLIC_ORIGIN unchanged. The beta proxies API and streaming requests server-side to the shared backend. No additional database, Redis, worker or backend instance is needed.

  1. Back up the existing database and storage, and rehearse the upgrade on staging. Apply backend migrations once with the backend release. The beta’s feedback/provider-status migrations follow the release’s existing chain; confirm alembic heads reports a single head.

  2. Copy docs/deployment/env_web_next.template to env_web_next.env. Generate a separate SESSION_SECRET with openssl rand -base64 48, and set LEGACY_APP_URL=https://app.example.com for the return link in the beta profile menu. Protect the file as you protect other deployment secrets.

  3. Set WEB_NEXT_HOST=beta.example.com in the deployment .env. The image comes from the release bundle’s ENEO_WEB_NEXT_IMAGE, so the beta always runs the build verified with the backend you deploy. Merge the optional overlay into the reference stack:

    docker compose --env-file .env --env-file release.env \ -f docker-compose.yml -f docker-compose.web-next.yml --profile web-next up -d
  4. For backend-managed federation, add https://beta.example.com/login/callback to each applicable federation configuration’s additional_redirect_uris, and register the same URI with the identity provider. The existing canonical callback stays registered. Use backend-managed authentication for the beta rollout. The separate frontend-managed OIDC flow remains experimental: concurrent requests can reuse a one-time rotating refresh token and invalidate the session. Refresh ownership is not coordinated across requests or replicas. Before releasing that mode, implement shared refresh coordination and validate the intended provider’s rotation behavior. Its development configuration uses the beta’s OIDC_* variables and the client callback https://beta.example.com/auth/callback.

  5. For SharePoint and Confluence, retain OAUTH_CALLBACK_URL for the existing UI and add this backend runtime variable (a JSON array):

    OAUTH_CALLBACK_URLS=["https://beta.example.com/integrations/callback/token/"]

    Register both full callback URLs with the provider, including the trailing slash. The backend accepts only exact registered values and binds the chosen callback to the initiating user’s single-use OAuth state. Existing clients that omit a callback keep using the default. Restart the backend after changing its environment; finish or restart in-flight integration logins during upgrade.

  6. Verify password/federated login, logout, streaming chat, files, tenant permissions and both integration providers in each UI before inviting users. Each UI has its own host-only session cookie. SSO can reuse the provider session, but a local logout in one UI does not necessarily log out the other.

Enable the invitation banner

Set these Svelte frontend runtime variables and restart that frontend:

SHOW_NEW_APP_BANNER=true NEW_APP_URL=https://beta.example.com

The flag defaults to false. Both an enabled flag and a valid HTTP(S) destination are required. The invitation appears in the signed-in shell and opens a new tab, leaving the current app available. Setting the flag back to false hides it; the beta remains reachable by its URL. The banner is discoverability, not access control.

To withdraw the beta, disable the invitation and stop the web-next service or remove its ingress. Keep the shared backend and database running. Do not downgrade the shared database simply to turn off the beta; a backend rollback is a separate operation that requires a tested migration/restore plan and preserved user data.

Before release, require the normal CI/E2E checks, source/dependency/secret scans and the deployment’s application-image security checks. Review the temporary dependency mitigation in frontend/patches/SECURITY.md. Scanner success alone does not prove that authentication, tenant separation or accessibility is correct in the actual deployment; complete the staging checks with the configured providers.