Deployment: uvicorn workers, Docker and reverse proxies
Run more than one worker safely, configure settings from the environment, write a real Dockerfile, and place the app behind a reverse proxy.
Workers
# development: one process, reload on change
uvicorn app.main:app --reload --port 8000
# production: several processes, no reload
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --no-access-log
# or a process manager around uvicorn
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --timeout 60 --graceful-timeout 30| Setting | Rule of thumb |
|---|---|
| Workers per container | (2 x vCPU) + 1 for mixed I/O workloads; measure your own |
| Timeout | Above your slowest legitimate request, below the proxy's timeout |
| Graceful timeout | Long enough for in-flight requests to finish on deploy |
| Memory | Size the container for workers x per-process footprint |
Each worker is a separate process with its own memory. Module-level caches, in-memory rate limits and BackgroundTasks state are therefore per-worker, which is the most common source of "it works on one instance" bugs.
Settings and the container image
# app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")
database_url: str
secret_key: str
debug: bool = False
allowed_origins: list[str] = []
@lru_cache
def get_settings() -> Settings:
return Settings()FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]- Copy the requirements file before the source so dependency layers are cached across code changes.
--no-cache-dirkeeps the image small;PYTHONUNBUFFEREDmakes logs appear immediately.- Run as a non-root user, and never bake secrets into the image.
Behind a reverse proxy
location / {
proxy_pass http://api:8000;
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;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://api:8000/health;
access_log off;
}⚠️
Never run
--reload in production: it watches the filesystem, doubles memory and can restart mid-request. Equally, do not rely on a startup hook to run migrations — with several workers they race, so migrate as a separate deployment step.FAQ
Why does my app think the request came from the proxy's IP?
The client address is in
X-Forwarded-For once a proxy is in front. Set --proxy-headers and --forwarded-allow-ips so uvicorn trusts only your proxy, since a client can forge that header otherwise.How many workers should I use?
Start at two per vCPU and measure. For an async app that mostly waits on a database, more workers than cores often helps; for CPU-bound work it merely adds context switching. Tune with a load test rather than a formula.
Related
Background tasks and lifespan events Routers, project layout and API versioning
Last refreshed 2026-09-18.