Docs menu
Deploying Flask
Flask on Primcloud: Procfile, WSGI server choice, secret management, static files, and the Flask-specific quirks worth knowing.
On this page
Flask deploys on Primcloud the same as any other Python app. Primcloud detects your repo, installs Python and dependencies, and hands the image to your Procfile. Flask is smaller than Django and gives you no built-in production server, so the shape of a Flask deploy is essentially: pick a WSGI server (or ASGI, for async views), point it at your app factory, and bind to $PORT.
Starter template#
There's no official Heroku "Flask getting started" sample the way there is for Django, but a minimal working shape is small enough to type. Create a repo with these four files at the root:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello from Flask on Primcloud."
flask==3.0.3
gunicorn==23.0.0
3.14
web: gunicorn app:app --bind 0.0.0.0:$PORT
Commit, push, hit Import Git Repository in the dashboard, and the app deploys. app:app is the module-and-callable Gunicorn imports: app.py's app variable.
For anything more than a proof of concept, use an application factory (create_app()) and a wsgi.py entrypoint, covered under Framework gotchas below.
Procfile for Flask on Primcloud#
The minimum Flask Procfile is a single web line running a WSGI server. Common shapes:
Gunicorn (WSGI, the default choice):
web: gunicorn app:app --bind 0.0.0.0:$PORT
Gunicorn with an app factory:
web: gunicorn "wsgi:create_app()" --bind 0.0.0.0:$PORT
Uvicorn (ASGI, if you're using async views via ASGI middleware like asgiref.wsgi.WsgiToAsgi):
web: uvicorn wsgi:asgi_app --host 0.0.0.0 --port $PORT
Warning
Flask's built-in dev server (flask run) is not production-appropriate: it's single-threaded, not resilient, and prints a warning telling you not to use it in production. Always ship a real WSGI/ASGI server.
Add other process types as you need them:
release: python -m migrations.apply
web: gunicorn wsgi:app --bind 0.0.0.0:$PORT --workers 2
worker: rq worker --url $REDIS_URL default
The release process runs once per deploy, before pods take traffic. Anything that has to happen once per rollout (schema migrations via Flask-Migrate/Alembic, cache warms, config validation) belongs there. Failure fails the deploy and rolls back.
Env vars this framework expects#
Flask reads almost nothing from the environment by default. Most configuration comes from calls in your app's factory. The exceptions:
| Variable | Purpose | Where it gets set |
|---|---|---|
FLASK_APP |
The import path Flask CLI commands (flask db upgrade, flask shell) target. Not needed for Gunicorn/Uvicorn, but needed if you run flask ... from a release or worker process. |
Secrets panel |
SECRET_KEY |
Flask's session-signing key. Read in your factory via app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]. Never commit it. |
Secrets panel |
DATABASE_URL |
Postgres/MySQL connection URL. Read via os.environ["DATABASE_URL"] and hand to SQLAlchemy or your ORM. |
Secrets panel |
PORT |
The port the web process must bind to. |
Set by Primcloud; don't set it yourself. |
PYTHONUNBUFFERED |
Ensures stdout/stderr flush immediately so logs aren't lost. |
Set by Primcloud; you don't need to set it. |
A typical Flask config module:
import os
class Config:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
SQLALCHEMY_TRACK_MODIFICATIONS = False
SESSION_COOKIE_SECURE = True
Set the values in the Secrets panel in the dashboard. Every value is stored encrypted and never surfaced in build logs.
Warning
A note on Flask's own env vars. FLASK_ENV is deprecated in Flask 2.3+; FLASK_DEBUG=1 should never be set on a deployed app (it enables the debugger, which is a remote code execution vector on a public URL).
Framework gotchas on Primcloud#
App factory pattern. For anything more than a single-file app, use an application factory rather than a module-level app = Flask(__name__). It makes testing easier and lets you configure the app based on environment cleanly:
from myapp import create_app
app = create_app()
Then your Procfile runs gunicorn wsgi:app --bind 0.0.0.0:$PORT.
Worker count and memory. Gunicorn's default is one worker. For a small-to-medium Flask app on Standard 1x compute, --workers 2 is a reasonable starting point, enough to handle concurrent requests without exhausting memory. The Metrics card on the environment's overview charts the web formation's memory as a percentage of its compute size; lower the worker count or move to a larger compute size as it nears the cap.
Static files. Flask serves static files out of static/ in development, but doing so through Gunicorn in production is wasteful. Use WhiteNoise to serve static assets directly from Gunicorn without extra machinery:
from whitenoise import WhiteNoise
from myapp import create_app
app = create_app()
app.wsgi_app = WhiteNoise(app.wsgi_app, root="static/", prefix="static/")
Or ship them to an object store (S3, GCS, DigitalOcean Spaces) and reference them by absolute URL.
Database migrations. Flask-Migrate wraps Alembic. Put the migration command in the release process:
release: flask db upgrade
For this to work, FLASK_APP must be set in the Secrets panel so flask knows which app to import.
Read-only filesystem expectations. Pods have a writable filesystem that resets on restart. Anything that must persist across restarts belongs in Postgres, an object store, or an external service, not on disk. Flask's send_from_directory is fine for read-only assets baked into the build; uploaded files need external storage.
Preload the app under Gunicorn. gunicorn --preload loads your app once in the master process before forking workers, which saves memory and speeds up boot when workers share large imports (ML models, big config trees). Trade-off: any error during app import fails the whole process rather than being scoped to one worker.
web: gunicorn wsgi:app --bind 0.0.0.0:$PORT --workers 2 --preload
Timeouts. Gunicorn's default worker timeout is 30 seconds. Long-running requests get killed. For legitimately long endpoints (large file uploads, slow third-party APIs), bump --timeout; better, move the work to a worker process backed by a queue.
Troubleshooting#
502 or connection refused after Release Succeeded. Almost always a bind issue. Confirm the web line has --bind 0.0.0.0:$PORT (not 127.0.0.1, not a fixed port).
ModuleNotFoundError: No module named 'app' (or your app module). Gunicorn couldn't import the WSGI callable. Check:
- The module path in the
Procfilematches your directory structure. - If you use an app factory, the callable syntax is quoted:
gunicorn "wsgi:create_app()". requirements.txt(or your lockfile) actually includes Flask and Gunicorn.
RuntimeError: The session is unavailable because no secret key was set. SECRET_KEY isn't set in the Secrets panel, or your create_app() isn't reading it. Set it in the dashboard and redeploy.
Workers timing out. Gunicorn kills workers that don't respond within --timeout (default 30s). Either bump the timeout for legitimately slow work, or offload it to a queue with a worker process type.
For anything not covered here, Troubleshooting Python has the general Python-on-Primcloud error catalog.