Skip to Content

Deploy a Flask App 🌶️

Push your Flask project to GitHub or GitLab 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 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.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:$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 bundled frontend, build it before you push and commit the output to static/: the Python build has no npm. Keep the frontend’s package.json in a subfolder such as frontend/, because a package.json at the repository root makes Faable build the project as a Node app.

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

Leave the worker count to Faable. The generated command runs a single gunicorn worker, and that is the right setting: don’t add --workers to your start command.

Every worker is a separate process that loads your whole app and its libraries, so each one multiplies the memory the app needs — two workers of an app that imports pandas or an AI SDK can be more than a small instance holds, and the app gets killed for running out of memory. If one worker isn’t enough, move to a larger instance. On the Free plan an app runs at most one worker — see Workers and memory.

Choosing the Python version

A new app gets the newest supported minor — Python 3.14 today — and keeps the minor of its first successful deploy from then on. Supported minors are 3.10, 3.11, 3.12, 3.13 and 3.14 — 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 — leave it to the start command Faable generates rather than setting --workers. Each worker loads its own copy of your app, so more workers mean proportionally more memory. For more capacity, pick a larger instance from the catalog.

Which Python versions does Faable support for Flask?

3.10, 3.11, 3.12, 3.13 and 3.14. A new app gets the newest (3.14 today) and keeps the minor of its first deploy; pin your choice with runtime.txt (python-3.13), .python-version, or requires-python in pyproject.toml.

Last updated on