Skip to Content

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 forWhich gives it
requirements.txt, pyproject.toml or PipfileYour 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.pyapp.pyasgi.pywsgi.pyapplication.pyserver.pyapp/main.pyapp/app.pysrc/main.py

Then it runs, with no configuration from you:

gunicorn <module>:app --bind 0.0.0.0:$PORT

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

That 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), 500

Two details that matter once it’s live:

  • Log to stdout. app.logger goes to the deployment logs in the dashboard and to faable 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:

  1. Create a Project and an App in the Faable Dashboard .
  2. Click Link repository and pick your Flask repo.
  3. Push to your release branch. Faable builds and takes it live.

Or from your laptop:

npm i -g @faable/faable faable login faable deploy

Your 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.12

Or 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 set startCommand.
  • 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 touching current_app or the database runs at import time. Move it inside a request handler or an app-context block.
  • ModuleNotFoundError at boot — the dependency isn’t in requirements.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.

Last updated on