If you've ever seen Flask print WARNING: This is a development server. Do not use it in a production deployment — that's the whole reason this post exists. Flask's built-in server is single-threaded and not hardened. Production means a real WSGI server. Here's how to do it right.
Why Gunicorn
Flask apps speak WSGI. Gunicorn is a battle-tested WSGI server that manages multiple worker processes, restarts crashed workers, and handles graceful shutdown. The basic production command:
gunicorn 'app:create_app()' \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 60If you use the application factory pattern, point Gunicorn at the factory call as shown. Otherwise it's just gunicorn app:app.
Choosing a worker model
Gunicorn supports several worker types, and picking the right one is the single biggest performance decision:
| Worker class | Best for | Notes |
|---|---|---|
sync (default) | CPU-bound, simple apps | One request per worker at a time |
gthread | Mixed I/O | Threads per worker; set --threads |
gevent / eventlet | High-concurrency I/O | Async via greenlets; needs monkey-patching |
For a typical CRUD API talking to a database, gthread with a few threads per worker is a solid default:
gunicorn 'app:create_app()' --workers 3 --threads 4 --worker-class gthread --bind 0.0.0.0:8000Start with (2 * cores) + 1 workers and adjust based on real latency and memory metrics.
A production Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--workers", "3", "--threads", "4", \
"--worker-class", "gthread", "--bind", "0.0.0.0:8000"]Running as a non-root user is a cheap, important hardening step.
Configuration and secrets
Flask reads config from environment variables cleanly:
import os
class Config:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
DEBUG = FalseNever run with DEBUG=True in production — it exposes the Werkzeug interactive debugger, which is a remote code execution risk if reachable. Set a real SECRET_KEY so sessions and CSRF tokens are secure.
A managed database
With Flask-SQLAlchemy, point at DATABASE_URL and run migrations with Flask-Migrate (Alembic under the hood):
flask db upgradeRun this as a deploy step before traffic hits the new code. Mind your connection pool size relative to workers * threads so you don't exceed your database's connection limit.
Health checks
@app.get("/healthz")
def healthz():
return {"status": "ok"}, 200Keep liveness cheap; add a separate readiness route that pings the database if you want the orchestrator to gate traffic on DB availability.
Deploying on PandaStack
- 1Create a PostgreSQL (or MySQL) database —
DATABASE_URLis injected automatically. - 2Connect your repo as a container app. PandaStack detects Python and your Dockerfile; without one, buildpacks install
requirements.txtand run Gunicorn. - 3Set
SECRET_KEYand other secrets in the dashboard. - 4Add
flask db upgradeas a release command and push.
Builds run in rootless BuildKit; you get live logs, automatic SSL, rollbacks, and deploy history.
Common pitfalls
- Shipping the dev server — never
flask runorapp.run()in production. DEBUG=True— exposes the interactive debugger; massive security hole.- Default
syncworker for I/O-heavy apps — they block; usegthreadorgevent. - Pool size mismatch —
workers * threadsmust fit your DB connection limit. - Binding to
127.0.0.1— unreachable from outside the container; use0.0.0.0.
References
- Flask deployment options: https://flask.palletsprojects.com/en/stable/deploying/
- Gunicorn design & worker types: https://docs.gunicorn.org/en/stable/design.html
- Flask-Migrate: https://flask-migrate.readthedocs.io/
- Werkzeug debugger security note: https://werkzeug.palletsprojects.com/en/stable/debug/
- Flask configuration handling: https://flask.palletsprojects.com/en/stable/config/
---
PandaStack's free tier includes container apps and a managed database with DATABASE_URL auto-injected — deploy your Gunicorn-served Flask app with automatic SSL and live logs. Start at https://dashboard.pandastack.io