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.0Create a start.sh script:
#!/bin/bash
alembic upgrade head
gunicorn -w 4 -b 0.0.0.0:8000 app:appThis runs database migrations before starting Gunicorn with 4 worker processes. Make it executable:
chmod +x start.shThen 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 workersYour API is live at https://.
Set up Alembic migrations
Install Alembic and initialize the migration directory:
pip install alembic
alembic init migrationsEdit migrations/env.py to import your models:
from app import db
target_metadata = db.metadataThen configure alembic.ini to read DATABASE_URL:
sqlalchemy.url = %(DATABASE_URL)sCreate your first migration:
alembic revision --autogenerate -m "Initial schema"
alembic upgrade headThe 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-deployThen 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:appThe 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:applicationPandaStack 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)