Deploy a Flask App 🌶️
Push your Flask project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no gunicorn command to write. The builder detects Flask from your repo, installs your dependencies, and serves it behind automatic SSL at https://<app>.faable.link, hosted 100% in Europe.
What Faable detects
Detection is file-based (full rules). For Flask the builder needs:
| It looks for | Which gives it |
|---|---|
requirements.txt, pyproject.toml or Pipfile | Your dependencies — installed into a virtualenv |
A module defining app = Flask(...) | The WSGI entrypoint → the start command below |
It searches for that module in this order, preferring the file that actually defines app:
main.py → app.py → asgi.py → wsgi.py → application.py → server.py → app/main.py → app/app.py → src/main.py
Then it runs, with no configuration from you:
gunicorn <module>:app --bind 0.0.0.0:$PORTgunicorn is installed automatically if it isn’t in your dependencies — though pinning it is good practice.
The one thing to get right: $PORT
Faable assigns your app a port at runtime and passes it as the PORT environment variable. The auto-detected command already binds 0.0.0.0:$PORT, so a standard project needs no change.
Note that app.run() is Flask’s development server — Faable never calls it, and you shouldn’t either in production. Keep it guarded for local use:
import os
if __name__ == "__main__":
# Local development only. On Faable, gunicorn serves the app.
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))A minimal deployable app
# app.py
import os
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return {"status": "ok"}
@app.get("/healthz")
def healthz():
return {"ok": True}# requirements.txt
Flask>=3.0
gunicornThat is a complete, deployable repo — two files.
A JSON API, end to end
Most Flask projects deployed on Faable are REST APIs — a service another app or a bot calls over HTTP. Here is a complete one with the pieces that are easy to get wrong in production: JSON errors instead of HTML, CORS for a browser client, config from the environment, and a health check.
# requirements.txt
Flask>=3.0
flask-cors>=5.0
gunicorn# app.py
import os
from flask import Flask, jsonify, request
from flask_cors import CORS
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
# Only the origins you actually serve — never "*" on an API that takes a key.
CORS(app, resources={r"/v1/*": {"origins": os.environ.get("ALLOWED_ORIGINS", "").split(",")}})
API_KEY = os.environ["API_KEY"]
@app.before_request
def require_key():
if request.path.startswith("/v1/") and request.headers.get("X-API-Key") != API_KEY:
return jsonify(error="unauthorized"), 401
@app.get("/v1/items")
def list_items():
# Page from the query string, clamped — an unbounded limit is how APIs fall over.
limit = min(int(request.args.get("limit", 20)), 100)
return jsonify(items=fetch_items(limit), limit=limit)
@app.post("/v1/items")
def create_item():
body = request.get_json(silent=True) or {}
if not body.get("name"):
return jsonify(error="name is required"), 422
return jsonify(item=save_item(body)), 201
@app.get("/healthz")
def healthz():
return {"ok": True}
@app.errorhandler(HTTPException)
def json_errors(error):
"""Flask returns HTML error pages by default — clients want JSON."""
return jsonify(error=error.name, status=error.code), error.code
@app.errorhandler(Exception)
def unhandled(error):
app.logger.exception("unhandled error")
return jsonify(error="internal server error", status=500), 500Two details that matter once it’s live:
- Log to stdout.
app.loggergoes to the deployment logs in the dashboard and tofaable deploy logs. Don’t write log files — the filesystem is ephemeral. - Don’t hold state in memory. A module-level cache or counter is lost on every deploy, and on every wake from sleep. Use a database — see Databases.
Configuration and secrets
Read configuration from the environment, never from committed files:
import os
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL")Set them with the CLI or the dashboard:
faable deploy secrets set SECRET_KEY=… DATABASE_URL=postgres://…Faable also injects PORT, FAABLE_APP_ID, FAABLE_RELEASE and FAABLE_GIT_COMMIT. See Environment & Releases.
Static files and templates
Flask serves its own static/ folder and Jinja templates from the app process, so both work unchanged — no extra configuration. For a heavier asset pipeline (a bundled frontend), build it at deploy time with a buildCommand in faable.json:
{
"buildCommand": "npm ci && npm run build"
}Deploy
From the dashboard — the normal path:
- Create a Project and an App in the Faable Dashboard .
- Click Link repository and pick your Flask repo.
- Push to your release branch. Faable builds and takes it live.
Or from your laptop:
npm i -g @faable/faable
faable login
faable deployYour app is live at https://<app>.faable.link with automatic SSL and the WAF already inspecting traffic.
The application factory pattern
If you use create_app() instead of a module-level app, the builder can’t detect an entrypoint — it looks for an assigned app object. Either expose one:
# wsgi.py
from myapp import create_app
app = create_app()…or declare the start command yourself in faable.json:
{
"startCommand": "gunicorn 'myapp:create_app()' --bind 0.0.0.0:$PORT"
}Workers
The default is a single gunicorn worker. Flask is synchronous, so a CPU-bound or slow-I/O app benefits from more:
{
"startCommand": "gunicorn app:app --bind 0.0.0.0:$PORT --workers 4"
}Size the worker count to the instance you picked — more workers on a small instance will make things worse, not better.
Choosing the Python version
The default is Python 3.11. Supported minors are 3.10, 3.11, 3.12 and 3.13 — pin one with a runtime.txt:
python-3.12Or with .python-version, or requires-python in pyproject.toml. First match wins, in that order.
Troubleshooting
- Build fails with “no start command” — no module in the search list defines
app = Flask(...). Use the factory pattern above or setstartCommand. - The app runs locally but 502s on Faable — you’re relying on
app.run(). Production is served by gunicorn; make sure the app object is importable at module level. Working outside of application context— code touchingcurrent_appor the database runs at import time. Move it inside a request handler or an app-context block.ModuleNotFoundErrorat boot — the dependency isn’t inrequirements.txt.- Requests time out — the start command binds a hardcoded port instead of
$PORT.
FAQ
Do I need a Dockerfile to deploy Flask on Faable?
No. Faable finds the module that defines app = Flask(...) and generates the gunicorn command for you. A Dockerfile is the escape hatch for stacks the buildpacks don’t detect natively.
How do I deploy a Flask app that uses an application factory?
Expose a module-level app (for example in wsgi.py with app = create_app()), or set startCommand in faable.json to gunicorn 'myapp:create_app()' --bind 0.0.0.0:$PORT.
Does Faable use app.run() to start my Flask app?
No — app.run() is the development server. Faable starts your app with gunicorn bound to 0.0.0.0:$PORT. Keep app.run() behind an if __name__ == "__main__" guard for local development.
How many gunicorn workers should I run?
One by default. Increase it with startCommand only if your workload is CPU-bound or blocking, and size the count to the instance you picked from the catalog.
Which Python versions does Faable support for Flask?
3.10, 3.11, 3.12 and 3.13. The default is 3.11; pin your choice with runtime.txt (python-3.13), .python-version, or requires-python in pyproject.toml.
Related
- What the Builder Expects — full detection and start-command rules
- Deploy a WhatsApp Bot · Deploy a Telegram Bot — webhook bots on this stack
- Deploy Next.js · Deploy Django · Deploy FastAPI · Deploy Node.js Express
- Environment & Releases · Custom domains · WAF
- Add authentication to your app — Faable Auth is included in the same subscription
Last updated on