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 runis 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.
gthreadthreads help with I/O-bound views; they do not make CPU-bound Python parallel. --timeoutkills 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 intorequest.is_secure. x_for=1means 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_sizeat least as large asMAX_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
.dockerignorethat 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.txtas the single source of dependencies so local and image builds agree. - Run
flask db upgradeonce 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.
Related
Configuration and project structure File uploads, static files and sending email
Last refreshed 2026-09-18.