Flask is deliberately minimal, which means production setup is entirely your problem. The development server that flask run starts is single-threaded and explicitly not meant for real traffic — the Flask docs say so on every page about deployment. So going to production means answering four questions: what serves the app, how it binds to a port, where the database credentials come from, and when migrations run. Here's a setup that answers all four and deploys on git push.
The production server: Gunicorn
Do not ship flask run. Use Gunicorn (or uWSGI, but Gunicorn is simpler and what most people should pick). Add it to your requirements:
flask
gunicorn
flask-sqlalchemy
flask-migrate
psycopg2-binaryIf your app is a plain module with a global app object:
gunicorn --bind 0.0.0.0:8000 app:appIf you use the application factory pattern (you should — it makes testing and config sane), Gunicorn can call the factory directly:
gunicorn --bind 0.0.0.0:8000 "app:create_app()"Two flags worth setting from day one:
gunicorn --bind 0.0.0.0:${PORT:-8000} \
--workers 2 --threads 4 \
--timeout 60 \
"app:create_app()"--workers 2 --threads 4 gives you concurrency without eating memory on a small container. --timeout 60 kills workers stuck on a slow request instead of letting them wedge the whole process. Binding to 0.0.0.0 matters: containers route traffic to the pod IP, and Gunicorn's default of 127.0.0.1 will make your app unreachable while looking perfectly healthy in the logs.
Reading DATABASE_URL correctly
The classic Flask + SQLAlchemy config reads a single connection string:
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
def create_app():
app = Flask(__name__)
url = os.environ["DATABASE_URL"]
# SQLAlchemy 1.4+ dropped the old postgres:// scheme alias.
# Some platforms still hand it out — normalize defensively.
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
app.config["SQLALCHEMY_DATABASE_URI"] = url
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
db.init_app(app)
migrate.init_app(app, db)
from .routes import bp
app.register_blueprint(bp)
return appThat postgres:// → postgresql:// rewrite is the single most common Flask deployment failure I see. SQLAlchemy 1.4 removed the deprecated postgres:// dialect name, and the error you get (Can't load plugin: sqlalchemy.dialects:postgres) doesn't obviously point at the URL scheme. Handle both and forget about it.
Notice the config also refuses to start without SECRET_KEY. Using os.environ["SECRET_KEY"] (with brackets, not .get()) means a missing variable crashes at boot instead of silently signing sessions with None. Fail fast on config; it's much easier to debug a crash-on-start than a subtle session bug.
Migrations with Flask-Migrate
Flask-Migrate wraps Alembic and gives you the flask db command group. Locally, the workflow is:
flask db init # once, creates migrations/
flask db migrate -m "add users" # autogenerate a revision
flask db upgrade # apply itCommit the migrations/ directory to git — the revision files are code, not artifacts.
In production, the only command you ever run is flask db upgrade. Never flask db migrate against a production database: autogeneration diffs your models against the live schema and can produce destructive operations you didn't review.
Where to run it matters too. The tempting move is to call upgrade() inside create_app() so migrations "just happen" on boot. Don't. If your platform runs two replicas, both will race on the same Alembic version table at startup. Run migrations as a discrete step before the new version takes traffic — a release command, or at minimum a startup script that runs it once before exec'ing Gunicorn:
#!/bin/sh
set -e
flask db upgrade
exec gunicorn --bind 0.0.0.0:${PORT:-8000} --workers 2 --threads 4 "app:create_app()"For the flask CLI to find your factory, set FLASK_APP=app:create_app in the environment.
A Dockerfile for Flask
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "app:create_app()"]PYTHONUNBUFFERED=1 is not optional in containers — without it, Python buffers stdout and your logs arrive in bursts (or not at all when the process dies). The non-root USER line costs nothing and removes a whole class of container-escape concerns.
One thing that bites people: psycopg2-binary works fine in the slim image, but if you switch to plain psycopg2 you'll need libpq-dev and gcc installed, which bloats the image. Stick with the binary wheel unless you have a specific reason not to.
Deploying on PandaStack
With the app structured like this, the deploy itself is short:
- 1Create a managed PostgreSQL instance (14.x or 16.x are what run in production) from the PandaStack dashboard.
- 2Connect your Flask repo as a container app. If you committed the Dockerfile above, it's used as-is; without one, the Python buildpack is auto-detected and the build/start commands are inferred — you can override them if your entrypoint is unusual.
- 3Attach the database to the app.
DATABASE_URLis injected into the container environment automatically — thecreate_app()code above picks it up with zero extra configuration, no copying credentials between tabs. - 4Add
SECRET_KEY(and anything else your app validates at boot) in the app's environment variables. - 5Push to your connected branch. The build runs in a rootless BuildKit job, and you can watch the build logs stream live — if a pip install fails on a missing system library, you see it immediately rather than after a timeout.
Every subsequent git push builds and deploys the new version, and deployment history means a bad release is a rollback away rather than a scramble.
If you're on the free tier, one behavior to know about: idle apps scale to zero and cold-start on the next request. For a hobby Flask app that's a fine trade for a $0 bill; for anything latency-sensitive, a paid tier keeps the process warm on stable nodes.
The checklist
Before you call a Flask deploy done, verify each of these:
- Gunicorn, not
flask run— bound to0.0.0.0, workers and timeout set. DATABASE_URLparsed with thepostgres://rewrite — or you'll hit the dialect error on the first platform that uses the old scheme.SECRET_KEYfrom the environment, required at boot — never a hardcoded default that survives into production.flask db upgradeas a release step — never autogeneration in prod, never migrations insidecreate_app()with multiple replicas.PYTHONUNBUFFERED=1— so your logs are actually live.
None of this is exotic. It's the same handful of decisions every Flask app needs, made once, committed to the repo, and then never thought about again — which is exactly how deployment should feel. If you want to try the flow end to end with a database wired in automatically, spin it up on https://pandastack.io.