Scaling Open WebUI
Open WebUI is designed to scale with your needs, from a single user to organization-wide rollouts across entire enterprises and institutions. The steps below walk you through how to configure your deployment as your needs grow.
Open WebUI follows a stateless, container-first architecture, which means scaling it looks a lot like scaling any modern web application. Whether you're moving from a hobby setup to supporting a department, or growing from hundreds to thousands of users, the same set of building blocks apply.
This guide walks you through the key concepts and configurations at a high level. For exact environment variable details, see the Environment Variable Reference.
Understanding the Defaults
Open WebUI defaults to a self-contained, single-instance deployment. The standard Docker setup includes:
- Embedded SQLite for application data on a persistent volume
- Embedded ChromaDB (also backed by SQLite) for RAG embeddings
- A single Uvicorn worker, with no external database or Redis required
This approach was chosen to minimize time to deployment and operational overhead: you can start using Open WebUI without first provisioning separate database or coordination services. Keeping these dependencies local also simplifies air-gapped deployment when models and required assets are provisioned within the isolated environment.
Horizontal scaling requires shared persistence and coordination, so every replica operates on the same data. Before adding replicas or workers, replace the embedded database configuration with PostgreSQL and a client-server vector database, configure shared file storage, and add Redis for coordination. This separates state from individual instances, allowing the stateless application tier to scale independently behind a load balancer.
Step 1: Switch to PostgreSQL
When: You plan to run more than one Open WebUI instance, or you want better performance and reliability for your database. You should also switch if your SQLite file lives on anything other than a locally-attached SSD/NVMe: see the callout below.
Staying on SQLite is fine for: single-replica deployments, personal use, evaluation, home lab setups, and small teams, as long as the database file lives on a locally-attached SSD/NVMe and you're not running multiple replicas or workers. The 0.8 → 0.9 async-backend story only bites when webui.db is on network storage; on local disk, SQLite is fast, supported, and a perfectly reasonable default. No migration needed. Skip this step and move on to whichever later step you actually need.
SQLite stores everything in a single file and doesn't handle concurrent writes from multiple processes well. PostgreSQL is a production-grade database that supports many simultaneous connections.
What to do:
Set the DATABASE_URL environment variable to point to your PostgreSQL server:
DATABASE_URL=postgresql://user:password@db-host:5432/openwebui
Key things to know:
- Open WebUI does not migrate data between databases, so plan this before you have production data in SQLite.
- For high-concurrency deployments, tune
DATABASE_POOL_SIZEandDATABASE_POOL_MAX_OVERFLOWto match your usage patterns. See Database Optimization for detailed guidance. - Remember that each Open WebUI instance maintains its own connection pool, so total connections = pool size × number of instances.
- If you skip this step and run multiple instances with SQLite, you will see
database is lockederrors and data corruption. See Database Corruption / "Locked" Errors for details.
A good starting point for tuning is DATABASE_POOL_SIZE=15 and DATABASE_POOL_MAX_OVERFLOW=20. Keep the combined total per instance well below your PostgreSQL max_connections limit (default is 100).
For credential handling and the SQLCipher-encrypted SQLite option, see the Database section of the Hardening guide.
Why SQLite on network storage fails the moment you scale (or upgrade)
Since 0.9.0 the backend data layer is fully async (async SQLAlchemy + aiosqlite). That change made Open WebUI dramatically more concurrent and, as a side effect, made every pre-existing "SQLite is slow on NFS/CephFS/Azure Files" problem go from tolerable to fatal overnight. Many operators hit this right after upgrading from 0.8.x without changing anything else in their deployment.
The mechanism in one paragraph: SQLite's durability guarantee is fsync() on every commit. On local SSD that's ~100 μs. On NFS / CephFS / Azure Files / Kubernetes PVCs backed by network storage that's 50 to 500 ms, sometimes seconds. In the old sync backend, FastAPI's ~40-thread worker pool acted as a natural throttle, so slow storage meant "slow app." In the async backend there's no thread-pool ceiling: the asyncio loop schedules thousands of DB coroutines in parallel, every slow fsync keeps a connection checked out for the full duration, and the SQLAlchemy async pool (default pool_size=5 + max_overflow=10 = 15 connections) saturates almost instantly. You then see:
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00
Making the pool bigger just moves the breaking point. More connections means more concurrent slow fsyncs hitting the same slow storage; the filesystem is still the bottleneck.
On top of that, SQLite's WAL mode relies on a memory-mapped -shm file for cross-process coordination, and mmap over NFS is officially unreliable per SQLite upstream: with high async concurrency it can produce actual locking pathologies (deadlocks, PRAGMA journal_mode=WAL that starts but never completes, multi-minute stalls on trivial queries).
There is no setting that fixes this while SQLite stays on network storage. The three options are:
- Best: switch to PostgreSQL (this step). The DB server manages its own I/O against its own local storage. Your app reaches it over a network socket, but that hop is orders of magnitude cheaper than NFS
fsync, and Postgres was designed from day one for concurrent writers. This is the only supported configuration for multi-replica, multi-user, or Kubernetes/Swarm deployments. - Move
webui.dboff network storage onto a local SSD/NVMe. Only appropriate for single-node, low-user deployments. Your RAG files and uploads on NFS are fine: SQLite specifically is the problem, not the shared filesystem in general. - Temporary workaround if you cannot do either yet:
Serializes to a single async connection, trading concurrency for stability. Not supported long-term: plan the real migration.
DATABASE_POOL_SIZE=1 DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT=30000
The short version: sync backends throttled concurrency through thread pools, so slow storage just made things slow. Async backends allow massive concurrency, which means slow fsyncs stack up, connections stay checked out longer, the pool saturates, and the whole thing wedges. The same storage was tolerable before because the app wasn't asking it to do 20 concurrent fsyncs.
Step 2: Add Redis
When: You want to run multiple Open WebUI instances (horizontal scaling) or multiple Uvicorn workers.
Redis acts as a shared state store so that all your Open WebUI instances can coordinate sessions, websocket connections, and application state. Without it, users would see inconsistent behavior depending on which instance handles their request.
What to do:
Set these environment variables:
REDIS_URL=redis://redis-host:6379/0
WEBSOCKET_MANAGER=redis
ENABLE_WEBSOCKET_SUPPORT=true
Key things to know:
- Redis is not needed for single-instance deployments for basic functionality. However, without Redis, signing out does not revoke tokens: they remain valid until they expire (default: 4 weeks). If your deployment is production-facing or handles sensitive data, Redis is strongly recommended even for a single instance, or alternatively shorten
JWT_EXPIRES_INto limit exposure. See Token Revocation in the Hardening guide for details. - If you're using Redis Sentinel for high availability, also set
REDIS_SENTINEL_HOSTSand consider settingREDIS_SOCKET_CONNECT_TIMEOUT=5to prevent hangs during failover. - For AWS Elasticache or other managed Redis Cluster services, set
REDIS_CLUSTER=true. - Make sure your Redis server has
timeout 1800and a high enoughmaxclients(10000+) to prevent connection exhaustion over time. - For high-concurrency websocket streaming, review Redis Pub/Sub output buffer limits. Large Socket.IO events can disconnect Pub/Sub clients if Redis uses small default buffers; see WebSocket Pub/Sub Buffer Limits.
- A single Redis instance is sufficient for the vast majority of deployments, even with thousands of users. You almost certainly do not need Redis Cluster unless you have specific HA/bandwidth requirements. If you think you need Redis Cluster, first check whether your connection count and memory usage are caused by fixable configuration issues (see Common Anti-Patterns).
- Without Redis in a multi-instance setup, you will experience WebSocket 403 errors, configuration sync issues, and intermittent authentication failures.
For a complete step-by-step Redis setup (Docker Compose, Sentinel, Cluster mode, verification), see the Redis WebSocket Support tutorial. For WebSocket and CORS issues behind reverse proxies, see Connection Errors.
Step 3: Run Multiple Instances
When: You need to handle more users or want high availability (no downtime during deploys or if a container crashes).
Open WebUI is stateless, so you can run as many instances as needed behind a load balancer. Each instance is identical and interchangeable.
Before running multiple instances, ensure you have completed Steps 1 and 2 (PostgreSQL and Redis). You also need a shared WEBUI_SECRET_KEY across all replicas. Without it, users will experience login loops and 401 errors. For how to generate, store, and rotate that key (plus the matching OAUTH_SESSION_TOKEN_ENCRYPTION_KEY), see Secret Key in the Hardening guide. For a full pre-flight checklist, see the Core Requirements Checklist.
Option A: Container Orchestration (Recommended)
Use Kubernetes, Docker Swarm, or similar platforms to manage multiple replicas:
- Keep
UVICORN_WORKERS=1per container (let the orchestrator handle scaling, not the app) - Set
ENABLE_DB_MIGRATIONS=falseon all replicas except one designated "primary" pod to prevent migration race conditions: see Updates and Migrations for the safe procedure - Scale up/down by adjusting your replica count
Option B: Multiple Workers per Container (Last Resort)
On a single machine with no orchestrator at all, you can raise UVICORN_WORKERS:
UVICORN_WORKERS=4
Prefer more containers even on one machine: Docker Compose runs replicas on a single host perfectly well, and gets you restarts one at a time and a per-container memory limit.
Extra workers cost you everything replicas cost, PostgreSQL, Redis and a client-server vector database are all still required, and return none of the benefit. They share one container, so they share its memory limit and die with it, and they cannot be spread across machines when one stops being enough.
Offload HTTP Compression to the Load Balancer
Once a load balancer, ingress, or CDN sits in front of Open WebUI, let it handle HTTP response compression and disable the application-level compression middleware:
ENABLE_COMPRESSION_MIDDLEWARE=false
By default every Open WebUI worker compresses its own HTTP responses (JSON API responses and static assets) with ZStd/Brotli/Gzip. Profiling shows this costs roughly 3–4% CPU per worker, multiplied across every replica in a scaled deployment. Enabling compression at the proxy layer instead (e.g. Nginx gzip on;, Traefik's compress middleware, Cloudflare's default compression) keeps responses just as small on the wire while freeing that CPU on every worker, and lets CDNs cache static assets in pre-compressed form.
WebSocket traffic and streaming chat responses (SSE) are never compressed by this middleware anyway, so disabling it has no effect on the chat streaming path. If nothing in front of Open WebUI compresses responses, the main cost of disabling is a larger first (uncached) page load, several megabytes of JavaScript/CSS, and larger big-JSON payloads (long chat histories, large model lists), which matters mostly on slow or mobile links. See ENABLE_COMPRESSION_MIDDLEWARE for the full trade-off discussion.
WebSocket frames are compressed separately, by the websocket server itself, and that is worth switching off under heavy streaming:
UVICORN_WS_PER_MESSAGE_DEFLATE=false
Chat responses stream as a very small frame per token, so compressing each one costs processor time for every subscriber and saves almost nothing at that size. The frames that did benefit, a finished message or a set of sources, are a few hundred kilobytes at most even for a very long reply. See UVICORN_WS_PER_MESSAGE_DEFLATE.
Two more websocket settings matter once a deployment is large. Each open tab sends a heartbeat every thirty seconds by default, so an instance holding thousands of idle tabs handles a steady stream of messages carrying nothing; WEBSOCKET_HEARTBEAT_INTERVAL=60 halves that, at the cost of a disconnected user staying in the active count for longer, since the server holds a presence entry for four times the interval. And a response being streamed is held in Redis so a reconnecting browser can resume it, with the entry deleted as soon as that response finishes; REDIS_RESPONSE_STREAM_TTL puts an hour's expiry on the leftovers from workers killed mid-stream, which previously stayed forever. See WEBSOCKET_HEARTBEAT_INTERVAL and REDIS_RESPONSE_STREAM_TTL.
Pair It with Static Asset Caching at the Proxy
Disabling app-side compression works best when the proxy also caches the static assets aggressively, so the "larger first page load" downside effectively disappears: each browser downloads the (proxy-compressed) bundles once and then never asks for them again.
Open WebUI's frontend is a SvelteKit app: all of its JavaScript/CSS lives under /_app/immutable/ with content-hashed filenames. A given URL never changes content, an upgrade produces new filenames, so these files are safe to cache essentially forever. The HTML shell and /_app/version.json are the opposite: they must stay short-lived, because they are how browsers discover a new build (Open WebUI polls version.json to detect upgrades and reload).
Example for Nginx:
proxy_cache_path /var/cache/nginx/openwebui levels=1:2 keys_zone=OPENWEBUI_STATIC:10m
max_size=1g inactive=7d use_temp_path=off;
# Content-hashed SvelteKit bundles — immutable by construction, cache "forever"
location ^~ /_app/immutable/ {
proxy_pass http://openwebui;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering on;
proxy_cache OPENWEBUI_STATIC;
proxy_cache_valid 200 7d;
proxy_cache_lock on;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}- The
immutablekeyword stops browsers from revalidating on reload/F5,max-agealone doesn't. If a year feels uncomfortable, 30 days (max-age=2592000, immutable) gives nearly the same effect; the hashed filenames make staleness impossible either way. - Do not apply long caching to the HTML shell or
/_app/version.json, leave those uncached or at a few minutes at most, or users won't pick up upgrades. proxy_cachemeans each asset is fetched from a worker once per cache lifetime instead of once per user, removing the static file serving load from the Python workers entirely.- If Nginx compresses on the fly, note that it compresses on every response (its proxy cache stores the uncompressed body), so prefer moderate levels,
gzip_comp_level 4;/ brotli quality 4–5 gets ~95% of the ratio of level 6 at roughly half the CPU, and setgzip_min_length 1000;so tiny responses skip the compressor.
Switch the JSON Encoder to orjson
Multiple instances mean Socket.IO events travel through Redis, and every one of them is encoded and decoded as JSON. That encoding was the single largest cost measured on the workers handling live updates in clustered deployments. Switching the application to orjson, a Rust implementation several times faster than Python's standard library, is a one-line change:
ENABLE_ORJSON=True
It covers HTTP request and response bodies, saving and opening chats (a whole conversation is encoded on every save and decoded again on every open), reading settings, the requests sent to model providers, upstream provider responses including the per-chunk parsing of streamed completions and the Socket.IO and Redis payloads. orjson already ships as a dependency, so nothing needs installing, and the setting is read once at startup. It is opt-in only because orjson is stricter about what it accepts, and anything it rejects falls back to the standard library automatically, so enabling it cannot turn a working payload into an error. Available from v0.11.0.
For the full breakdown of what it covers, the two behaviour differences worth knowing and when it is not worth enabling, see Multi-Replica → Use the Faster JSON Encoder.
Speed Up Name Lookups
Hostname lookups queue behind each other under load, delaying the request each one belongs to. The c-ares resolver removes that queue:
AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER=True
It is opt-in because c-ares reads fewer name sources than the operating system does. See DNS Resolver for the trade-off and what to test afterwards.
Step 4: Switch to an External Vector Database
When: You run more than one Uvicorn worker (UVICORN_WORKERS > 1) or more than one replica. This is not optional.
The default vector database (ChromaDB) uses a local PersistentClient backed by SQLite. SQLite connections are not fork-safe: when uvicorn forks multiple workers, each process inherits the same database connection. Concurrent writes (e.g., during document uploads) cause instant worker death:
save_docs_to_vector_db:1619 - adding to collection file-id
INFO: Waiting for child process [pid]
INFO: Child process [pid] died
This is a well-known SQLite limitation, not a bug. It also affects multi-replica deployments where multiple containers access the same ChromaDB data directory.
For the full crash sequence analysis, see Worker Crashes During Document Upload or RAG Troubleshooting: Worker Dies During Upload.
What to do:
Set the VECTOR_DB environment variable to a client-server vector database:
VECTOR_DB=pgvector
Recommended alternatives:
| Vector DB | Best For | Configuration |
|---|---|---|
| PGVector | Teams already using PostgreSQL, reuses your existing database infrastructure | VECTOR_DB=pgvector + PGVECTOR_DB_URL=postgresql://... |
| MariaDB Vector | HNSW-based vector search, performance comparable to other implementations, with stronger scalability under multi-connection workloads | VECTOR_DB=mariadb-vector + MARIADB_VECTOR_DB_URL=mariadb+mariadbconnector://... |
| Milvus | Large-scale self-hosted deployments with high query throughput; supports multitenancy for per-user isolation | VECTOR_DB=milvus + MILVUS_URI=http://milvus-host:19530 |
| Qdrant | Self-hosted deployments needing efficient filtering and metadata search; supports multitenancy | VECTOR_DB=qdrant + QDRANT_URI=http://qdrant-host:6333 |
| Pinecone | Fully managed cloud service, zero infrastructure to maintain, pay-per-use | VECTOR_DB=pinecone + PINECONE_API_KEY=... |
| ChromaDB (HTTP mode) | Keeping ChromaDB but making it multi-process safe by running it as a separate server | VECTOR_DB=chroma + CHROMA_HTTP_HOST=chroma-host + CHROMA_HTTP_PORT=8000 |
Only PGVector and ChromaDB will be consistently maintained by the Open WebUI team. The other vector stores are mainly community-added vector databases.
PGVector is the simplest choice if you're already running PostgreSQL for the main database. It adds vector search to the database you already have, with no additional infrastructure.
For maximum scalability in self-hosted environments, Milvus and Qdrant both support multitenancy mode (ENABLE_MILVUS_MULTITENANCY_MODE=True / ENABLE_QDRANT_MULTITENANCY_MODE=True), which provides better resource sharing at scale.