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
SettingRule of thumb
Workers per container(2 x vCPU) + 1 for mixed I/O workloads; measure your own
TimeoutAbove your slowest legitimate request, below the proxy's timeout
Graceful timeoutLong enough for in-flight requests to finish on deploy
MemorySize 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-dir keeps the image small; PYTHONUNBUFFERED makes 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.

Background tasks and lifespan events Routers, project layout and API versioning

Last refreshed 2026-09-18.