Deployment: gunicorn, nginx and Docker

Run Flask under a real WSGI server, put nginx in front for TLS and static files, and containerise the app without baking secrets into the image.

WSGI servers and gunicorn

pip install gunicorn

# factory pattern: resolve create_app() at start-up
gunicorn -w 4 -k gthread --threads 4 -b 127.0.0.1:8000 --timeout 60 \
  --access-logfile - --error-logfile - "app:create_app()"

# simpler layouts can point at a module-level app object
gunicorn -w 4 -b 127.0.0.1:8000 run:app

# reload workers without dropping connections
kill -HUP "$(cat /run/gunicorn.pid)"
  • flask run is a development server: one process, a reloader, and a warning on every start-up. It is not built to face the internet.
  • A worker is a process with its own memory. Session data in a signed cookie is fine; anything stored in a module-level dictionary, an in-process cache or local disk is per-worker and inconsistent as soon as you run more than one.
  • Size by memory first, then by CPU. Start at two workers per core, watch peak RSS, and reduce when the host starts swapping. gthread threads help with I/O-bound views; they do not make CPU-bound Python parallel.
  • --timeout kills a worker that has not responded. If you raise it, keep the proxy's timeout slightly higher so the proxy does not give up first and report a 502 for a request that is still running.

nginx in front

server {
    listen 443 ssl http2;
    server_name app.example.com;

    client_max_body_size 5m;              # >= MAX_CONTENT_LENGTH

    location /static/ {
        alias /srv/app/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    location / {
        proxy_pass http://127.0.0.1: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 65s;
    }
}
# trust the proxy headers, or request.remote_addr is 127.0.0.1
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)

# ProxyFix rewrites the scheme, so url_for(_external=True) generates https
# and request.remote_addr, request.host and request.is_secure become correct.
# Only ever wrap the app once, for the exact number of trusted proxies.
  • Terminate TLS at the proxy and redirect HTTP to HTTPS there. The application should see X-Forwarded-Proto: https, which ProxyFix translates into request.is_secure.
  • x_for=1 means exactly one trusted proxy sits in front. Setting it higher lets a client spoof the header and forge its own address in your logs and rate limiter.
  • Serve static files from the proxy or a CDN. Every asset request answered by nginx is one Python worker freed for application work.
  • Set client_max_body_size at least as large as MAX_CONTENT_LENGTH, otherwise uploads are rejected at the edge with a proxy error page instead of your own 413 handler.

Containers and release hygiene

FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /srv/app

FROM base AS deps
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM base AS runtime
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
COPY . .
RUN useradd --create-home --uid 10001 app && mkdir -p /srv/app/instance \
 && chown -R app:app /srv/app
USER app
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "--timeout", "60", "app:create_app()"]
docker build -t registry.example.com/app:1.4.2 .

# secrets arrive at run time, not at build time
docker run --rm -p 8000:8000 \
  -e FLASK_CONFIG=production \
  -e SECRET_KEY="$(cat /run/secrets/flask_key)" \
  -e DATABASE_URL="postgresql+psycopg://app:pw@db:5432/app" \
  registry.example.com/app:1.4.2

# schema first, then the new code, from a one-off container
docker run --rm -e FLASK_CONFIG=production registry.example.com/app:1.4.2 flask db upgrade
  • A .dockerignore that excludes .git, .env, instance/ and __pycache__ keeps the image small and stops local secrets from being copied in.
  • Run as a non-root user, pin base image and dependency versions, and keep the repository's requirements.txt as the single source of dependencies so local and image builds agree.
  • Run flask db upgrade once as its own job before starting new containers; with several replicas, a start-up migration races and can apply twice.
  • Log to stdout and stderr only. The platform collects them; a file inside the container disappears when the container is replaced.
⚠️
A secret baked into an image is not removable. Environment variables set with ENV, a .env copied by COPY . ., or a key committed in a config file all persist in image layers and in the registry's history, where anyone with pull access can read them. Pass secrets at run time from a secret manager or the orchestrator, keep them out of .dockerignore-included paths, and rotate anything that was ever committed — history keeps it forever.

FAQ

502 from nginx - where do I look?
The proxy could not reach a healthy worker. Check that gunicorn is bound to the address nginx proxies to, then the worker log for a crash on import. A factory that raises, a missing environment variable or a database that is not yet reachable all produce the same 502 from the outside.
How many gunicorn workers should I run?
Measure. Start with two per CPU core, load-test the real endpoints, and watch memory per worker. The limit is usually RAM — a container that exceeds its memory limit is killed mid-request, which shows up as intermittent 502s rather than a clean error.

Configuration and project structure File uploads, static files and sending email

Last refreshed 2026-09-18.