Back to Blog
Tutorial9 min read2026-07-11

How to Deploy Flask with PandaStack

Ship a Flask app to production the right way: Gunicorn, a managed PostgreSQL wired in via DATABASE_URL, Alembic migrations, and deploys on git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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-binary

If your app is a plain module with a global app object:

gunicorn --bind 0.0.0.0:8000 app:app

If 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 app

That 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 it

Commit 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:

  1. 1Create a managed PostgreSQL instance (14.x or 16.x are what run in production) from the PandaStack dashboard.
  2. 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.
  3. 3Attach the database to the app. DATABASE_URL is injected into the container environment automatically — the create_app() code above picks it up with zero extra configuration, no copying credentials between tabs.
  4. 4Add SECRET_KEY (and anything else your app validates at boot) in the app's environment variables.
  5. 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 to 0.0.0.0, workers and timeout set.
  • DATABASE_URL parsed with the postgres:// rewrite — or you'll hit the dialect error on the first platform that uses the old scheme.
  • SECRET_KEY from the environment, required at boot — never a hardcoded default that survives into production.
  • flask db upgrade as a release step — never autogeneration in prod, never migrations inside create_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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also