Back to Blog
Tutorial13 min read2026-08-01

Deploy Flask with Gunicorn and Managed PostgreSQL

Run Flask in production with Gunicorn as the WSGI server, connected to a managed Postgres database. Migrations via Alembic, connection pooling, and proper bind address config.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Flask's built-in development server prints a warning every time you start it: "Do not use it in a production deployment." That's because flask run handles one request at a time, has no process management, and crashes take your entire API offline. A production Flask app needs a WSGI server like Gunicorn, which spawns worker processes, restarts them on failure, and serves concurrent requests.

The second problem is database connections. Flask apps typically use SQLAlchemy, which defaults to creating a new connection per request. Without connection pooling, you'll hit the database connection limit after a few dozen concurrent users. PandaStack provisions a managed PostgreSQL instance with 50 connections on the free tier, but your application code has to manage them correctly or they'll leak.

Set up the Flask app

Your project structure should look like this:

app.py
requirements.txt
alembic.ini
migrations/
  env.py
  versions/

In app.py, configure SQLAlchemy to read DATABASE_URL from the environment:

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 10,
    'pool_recycle': 3600,
    'pool_pre_ping': True
}

db = SQLAlchemy(app)

class Order(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    product = db.Column(db.String(255))
    quantity = db.Column(db.Integer)

@app.route('/orders')
def get_orders():
    orders = Order.query.all()
    return jsonify([{'product': o.product, 'quantity': o.quantity} for o in orders])

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000)

The host='0.0.0.0' line is critical. Flask defaults to 127.0.0.1, which blocks external traffic in a container. Kubernetes health checks fail, and the deploy goes into a restart loop.

Add Gunicorn

In requirements.txt:

Flask==3.0.0
Flask-SQLAlchemy==3.1.1
psycopg2-binary==2.9.9
alembic==1.13.1
gunicorn==21.2.0

Create a start.sh script:

#!/bin/bash
alembic upgrade head
gunicorn -w 4 -b 0.0.0.0:8000 app:app

This runs database migrations before starting Gunicorn with 4 worker processes. Make it executable:

chmod +x start.sh

Then update your Procfile or package.json start script to call start.sh. If there's no Procfile, PandaStack will detect start.sh and use it automatically.

Provision the database

Create a PostgreSQL instance via the REST API:

curl -X POST https://api.pandastack.io/v1/databases \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "flask-orders-db",
    "engine": "postgresql",
    "version": "16"
  }'

Note the databaseId from the response. You'll link it to your Flask app in the next step.

Deploy and link the database

Use pandastack.json to declare the environment and dependencies:

{
  "type": "container",
  "language": "python",
  "startCommand": "./start.sh",
  "healthCheckPath": "/orders",
  "env": [
    { "key": "DATABASE_URL", "description": "Managed PostgreSQL connection string (auto-injected)" }
  ]
}

The healthCheckPath tells Kubernetes to poll /orders every 10 seconds. If it returns a 500 error (because the database is unreachable), the pod gets killed and restarted.

Deploy via the API:

curl -X POST https://api.pandastack.io/v1/projects \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "container",
    "name": "flask-orders-api",
    "repositoryName": "acme/flask-orders",
    "branch": "main",
    "autoDeploy": true,
    "databaseId": "<your-database-id>"
  }'

PandaStack builds a container, runs pip install -r requirements.txt, starts the app with ./start.sh, and injects DATABASE_URL automatically. The deploy log shows:

[builder] Installing dependencies
[builder] Collecting Flask==3.0.0
[deployer] Linked database flask-orders-db
[deployer] Injecting DATABASE_URL
[runner] Running migrations: alembic upgrade head
[runner] Starting Gunicorn with 4 workers

Your API is live at https://.pandastack.app/orders.

Set up Alembic migrations

Install Alembic and initialize the migration directory:

pip install alembic
alembic init migrations

Edit migrations/env.py to import your models:

from app import db
target_metadata = db.metadata

Then configure alembic.ini to read DATABASE_URL:

sqlalchemy.url = %(DATABASE_URL)s

Create your first migration:

alembic revision --autogenerate -m "Initial schema"
alembic upgrade head

The start.sh script runs alembic upgrade head before starting Gunicorn, so every deploy applies pending migrations automatically.

Connection pooling explained

The pool_size=10 option tells SQLAlchemy to maintain 10 persistent connections. Without it, every request opens a new connection and eventually exhausts the database's connection limit (50 on the free tier, 300 on Pro).

pool_pre_ping=True makes SQLAlchemy test each connection before using it. This prevents errors when a connection has been idle too long and the database closed it.

Use the CLI for faster iteration

If you're deploying repeatedly to test configuration changes, the CLI is faster than the API:

panda login
panda projects create \
  --name flask-orders-api \
  --repo acme/flask-orders \
  --branch main \
  --type container \
  --auto-deploy

Then link the database from the dashboard (Settings → Database), or use the API's PATCH /v1/projects/ endpoint to add the databaseId field.

Debugging common failures

sqlalchemy.exc.OperationalError: could not connect to server: DATABASE_URL is missing or malformed. Check the environment variables in the dashboard.

Health check failing: The /orders endpoint returns a 500 error, probably because the database connection failed. Check the app logs (Dashboard → Logs).

alembic.util.exc.CommandError: Can't locate revision identified by 'xyz': The migration history is out of sync. Either reset the database (dangerous) or manually sync the alembic_version table.

Pod restarting in a loop: Gunicorn is crashing. Check panda projects logs for the traceback. Common causes: missing requirements.txt dependency, start.sh not executable, or binding to 127.0.0.1 instead of 0.0.0.0.

Scale to more workers

The free tier runs one pod with 0.25 CPU and 512 MB RAM. Four Gunicorn workers will saturate that quickly. If you're on the Pro plan, upgrade to a larger compute tier (C1 or M1) and increase the worker count:

gunicorn -w 8 -b 0.0.0.0:8000 app:app

The general rule is 2-4 workers per CPU core. On a 2-CPU tier, use 4-8 workers.

What about Django?

The process is almost identical. Replace gunicorn app:app with gunicorn myproject.wsgi:application, and use Django's migrate command instead of Alembic:

#!/bin/bash
python manage.py migrate --noinput
gunicorn -w 4 -b 0.0.0.0:8000 myproject.wsgi:application

PandaStack detects Django projects automatically and injects DATABASE_URL, which Django reads via dj-database-url:

import dj_database_url
DATABASES = {
    'default': dj_database_url.config(conn_max_age=600)
}

References

  • [Flask deployment guide](https://flask.palletsprojects.com/en/3.0.x/deploying/)
  • [Gunicorn configuration](https://docs.gunicorn.org/en/stable/settings.html)
  • [SQLAlchemy engine options](https://docs.sqlalchemy.org/en/20/core/engines.html)
  • [PandaStack databases](https://docs.pandastack.io/databases)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also