Flask #
Flask is the most popular Python microframework for building web applications and APIs. With its “minimal by design” philosophy, Flask doesn’t impose a project structure, ORM, or form handling. You choose: SQLAlchemy or raw queries, Jinja templates or a React frontend, Flask-Login or manual JWT. This flexibility makes Flask a great fit for API microservices, internal dashboards, or small-to-mid-scale applications.
Docker Compose complements Flask consistently: the application, database, and cache run as separate containers. No need to install Postgres or Redis on the host. This article covers a Docker Compose setup for Flask local development, from Python Dockerfiles, hot reload, to SQLAlchemy, Celery, and best practices.
Prerequisites #
Make sure you have installed:
- Docker and Docker Compose (latest versions)
- Python 3.12+ (optional, for host-side development)
- pip or poetry
A standard Flask project structure using the Application Factory pattern:
my-flask-app/
├── app/
│ ├── __init__.py # Application factory
│ ├── config.py
│ ├── extensions.py # SQLAlchemy, Migrate, etc
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── api/
│ │ ├── __init__.py
│ │ └── users.py # Blueprint
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ └── utils/
│ └── errors.py
├── migrations/ # Alembic / Flask-Migrate
├── tests/
├── wsgi.py # Entry point
├── requirements/
│ ├── base.txt
│ └── dev.txt
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
├── .flaskenv
└── .dockerignore
The application factory is the idiomatic Flask pattern that separates app instance creation from configuration. It makes testing easier (a separate app per test) and enables multi-environment configuration.
A Dockerfile for Production #
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements/base.txt requirements.txt
RUN pip install --upgrade pip \
&& pip install --prefix=/install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
COPY . .
RUN addgroup -S flask && adduser -S flask -G flask
USER flask
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "3", "--access-logfile", "-", "wsgi:app"]
A slim production image with Gunicorn and a non-root user.
A Dockerfile for Development #
# Dockerfile.dev
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
FLASK_APP=wsgi.py \
FLASK_DEBUG=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements/dev.txt requirements.txt
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--debug"]
FLASK_APP=wsgi.py tells Flask the entry point. FLASK_DEBUG=1 enables debug mode and auto-reload.
.flaskenv and Configuration
#
The .flaskenv file (auto-loaded by python-dotenv through the Flask CLI):
FLASK_APP=wsgi.py
FLASK_DEBUG=1
FLASK_ENV=development
config.py:
import os
from datetime import timedelta
from urllib.parse import urlparse
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
"postgresql://flask:pass@db:5432/flaskapp",
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_pre_ping": True,
"pool_recycle": 300,
}
REDIS_URL = os.environ.get("REDIS_URL", "redis://cache:6379/0")
# Session & JWT
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
JWT_SECRET_KEY = os.environ.get("JWT_SECRET", "dev-jwt-secret")
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
# CORS
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
# Pagination
ITEMS_PER_PAGE = 20
# Logging
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
class DevelopmentConfig(BaseConfig):
DEBUG = True
TEMPLATES_AUTO_RELOAD = True
EXPLAIN_TEMPLATE_LOADING = False
SQLALCHEMY_ECHO = False
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False
class ProductionConfig(BaseConfig):
DEBUG = False
SESSION_COOKIE_SECURE = True
SQLALCHEMY_ECHO = False
config_by_name = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
The Application Factory #
app/__init__.py:
import os
from flask import Flask
from .config import config_by_name
from .extensions import db, migrate, jwt, cors, ma
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get("FLASK_ENV", "development")
app = Flask(__name__)
app.config.from_object(config_by_name[config_name])
# Init extensions
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
cors.init_app(app, resources={r"/api/*": {"origins": app.config["CORS_ORIGINS"]}})
ma.init_app(app)
# Register blueprints
from .api.users import users_bp
from .api.auth import auth_bp
from .api.health import health_bp
app.register_blueprint(health_bp, url_prefix="/")
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
app.register_blueprint(users_bp, url_prefix="/api/v1/users")
# Error handlers
from .utils.errors import register_error_handlers
register_error_handlers(app)
return app
app/extensions.py:
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_cors import CORS
from flask_marshmallow import Marshmallow
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
cors = CORS()
ma = Marshmallow()
wsgi.py:
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
Models with SQLAlchemy #
app/models/user.py:
import uuid
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from ..extensions import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
username = db.Column(db.String(80), unique=True, nullable=False)
full_name = db.Column(db.String(255), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
is_verified = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {
"id": self.id,
"email": self.email,
"username": self.username,
"full_name": self.full_name,
"is_active": self.is_active,
"is_verified": self.is_verified,
"created_at": self.created_at.isoformat(),
}
def __repr__(self):
return f"<User {self.email}>"
The Service Layer #
Separate business logic from route handlers. The service layer is where business validation, data transformation, and repository orchestration live.
app/services/user_service.py:
import re
from typing import Optional, List
from ..extensions import db
from ..models.user import User
class UserServiceError(Exception):
pass
class UserService:
@staticmethod
def _validate_email(email: str) -> str:
email = email.strip().lower()
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise UserServiceError("invalid email format")
return email
@staticmethod
def _validate_password(password: str) -> str:
if len(password) < 8:
raise UserServiceError("password must be at least 8 characters")
if len(password) > 72:
raise UserServiceError("password too long")
return password
def register(self, email: str, username: str, full_name: str, password: str) -> User:
email = self._validate_email(email)
password = self._validate_password(password)
if User.query.filter_by(email=email).first():
raise UserServiceError("email already registered")
if User.query.filter_by(username=username).first():
raise UserServiceError("username already taken")
user = User(email=email, username=username, full_name=full_name)
user.set_password(password)
db.session.add(user)
db.session.commit()
return user
def authenticate(self, email: str, password: str) -> Optional[User]:
user = User.query.filter_by(email=email.strip().lower()).first()
if not user or not user.check_password(password):
return None
return user
def get(self, user_id: str) -> Optional[User]:
return User.query.get(user_id)
def list(self, page: int = 1, per_page: int = 20) -> dict:
pagination = User.query.order_by(User.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return {
"data": [u.to_dict() for u in pagination.items],
"page": pagination.page,
"per_page": pagination.per_page,
"total": pagination.total,
"pages": pagination.pages,
}
def update(self, user_id: str, **kwargs) -> Optional[User]:
user = User.query.get(user_id)
if not user:
return None
for key, value in kwargs.items():
if key in ("email", "full_name", "is_active") and hasattr(user, key):
setattr(user, key, value)
db.session.commit()
return user
def delete(self, user_id: str) -> bool:
user = User.query.get(user_id)
if not user:
return False
db.session.delete(user)
db.session.commit()
return True
APIs with Blueprints #
app/api/users.py:
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.user_service import UserService, UserServiceError
users_bp = Blueprint("users", __name__)
user_service = UserService()
@users_bp.route("", methods=["POST"])
@jwt_required()
def create_user():
data = request.get_json() or {}
try:
user = user_service.register(
email=data.get("email", ""),
username=data.get("username", ""),
full_name=data.get("full_name", ""),
password=data.get("password", ""),
)
return jsonify(user.to_dict()), 201
except UserServiceError as e:
return jsonify({"error": str(e)}), 400
@users_bp.route("", methods=["GET"])
@jwt_required()
def list_users():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
return jsonify(user_service.list(page=page, per_page=per_page)), 200
@users_bp.route("/<string:user_id>", methods=["GET"])
@jwt_required()
def get_user(user_id):
user = user_service.get(user_id)
if not user:
return jsonify({"error": "user not found"}), 404
return jsonify(user.to_dict()), 200
@users_bp.route("/<string:user_id>", methods=["PUT"])
@jwt_required()
def update_user(user_id):
data = request.get_json() or {}
user = user_service.update(user_id, **data)
if not user:
return jsonify({"error": "user not found"}), 404
return jsonify(user.to_dict()), 200
@users_bp.route("/<string:user_id>", methods=["DELETE"])
@jwt_required()
def delete_user(user_id):
if not user_service.delete(user_id):
return jsonify({"error": "user not found"}), 404
return "", 204
@users_bp.route("/me", methods=["GET"])
@jwt_required()
def me():
user_id = get_jwt_identity()
user = user_service.get(user_id)
if not user:
return jsonify({"error": "user not found"}), 404
return jsonify(user.to_dict()), 200
app/api/auth.py:
from flask import Blueprint, request, jsonify
from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity
from ..services.user_service import UserService, UserServiceError
auth_bp = Blueprint("auth", __name__)
user_service = UserService()
@auth_bp.route("/register", methods=["POST"])
def register():
data = request.get_json() or {}
try:
user = user_service.register(
email=data.get("email", ""),
username=data.get("username", ""),
full_name=data.get("full_name", ""),
password=data.get("password", ""),
)
return jsonify(user.to_dict()), 201
except UserServiceError as e:
return jsonify({"error": str(e)}), 400
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json() or {}
user = user_service.authenticate(
email=data.get("email", ""),
password=data.get("password", ""),
)
if not user:
return jsonify({"error": "invalid credentials"}), 401
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({
"access_token": access_token,
"refresh_token": refresh_token,
"user": user.to_dict(),
}), 200
@auth_bp.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh():
user_id = get_jwt_identity()
access_token = create_access_token(identity=user_id)
return jsonify({"access_token": access_token}), 200
Error Handlers #
app/utils/errors.py:
from flask import jsonify
from werkzeug.exceptions import HTTPException
from sqlalchemy.exc import IntegrityError
class APIError(Exception):
def __init__(self, message, status_code=400, payload=None):
super().__init__()
self.message = message
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv["error"] = self.message
rv["status"] = self.status_code
return rv
def register_error_handlers(app):
@app.errorhandler(APIError)
def handle_api_error(err):
return jsonify(err.to_dict()), err.status_code
@app.errorhandler(HTTPException)
def handle_http(err):
return jsonify({"error": err.description, "status": err.code}), err.code
@app.errorhandler(IntegrityError)
def handle_integrity(err):
from ..extensions import db
db.session.rollback()
return jsonify({"error": "database integrity error"}), 409
@app.errorhandler(404)
def handle_404(err):
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)
def handle_500(err):
return jsonify({"error": "internal server error"}), 500
Health Checks #
app/api/health.py:
from flask import Blueprint, jsonify
from sqlalchemy import text
from ..extensions import db, jwt
health_bp = Blueprint("health", __name__)
@health_bp.route("/healthz", methods=["GET"])
def healthz():
try:
db.session.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
return jsonify({
"status": "ok" if db_ok else "degraded",
"database": "up" if db_ok else "down",
}), 200 if db_ok else 503
@health_bp.route("/readyz", methods=["GET"])
def readyz():
return jsonify({"status": "ready"}), 200
/healthz checks dependencies — returns 503 when the database is down. /readyz always returns 200 (server alive, ready to accept requests).
docker-compose.yml #
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
image: flask-app:dev
container_name: flask-api
command: flask run --host=0.0.0.0 --port=5000 --debug
volumes:
- ./:/app
ports:
- "5000:5000"
environment:
- FLASK_APP=wsgi.py
- FLASK_ENV=development
- FLASK_DEBUG=1
- DATABASE_URL=postgresql://flask:pass@db:5432/flaskapp
- REDIS_URL=redis://cache:6379/0
- SECRET_KEY=local-dev-secret-change-me
- JWT_SECRET=local-dev-jwt-secret
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
worker:
build:
context: .
dockerfile: Dockerfile.dev
command: celery -A app.celery worker --loglevel=info
volumes:
- ./:/app
environment:
- DATABASE_URL=postgresql://flask:pass@db:5432/flaskapp
- REDIS_URL=redis://cache:6379/0
depends_on:
- api
- cache
profiles: ["with-worker"]
db:
image: postgres:16-alpine
container_name: flask-db
environment:
- POSTGRES_USER=flask
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=flaskapp
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flask -d flaskapp"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: flask-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
- cache-data:/data
ports:
- "6379:6379"
volumes:
db-data:
cache-data:
The worker service is a Celery worker for background tasks. Enabled with --profile with-worker.
Celery for Background Tasks #
app/celery.py:
from celery import Celery
from celery.schedules import crontab
celery_app = Celery(
"flask-app",
broker="redis://cache:6379/1",
backend="redis://cache:6379/2",
include=["app.tasks"],
)
class ContextTask(celery_app.Task):
abstract = True
def __call__(self, *args, **kwargs):
from app import create_app
with create_app().app_context():
return self.run(*args, **kwargs)
celery_app.Task = ContextTask
# Schedule
celery_app.conf.beat_schedule = {
"cleanup-expired-tokens": {
"task": "app.tasks.cleanup_expired_tokens",
"schedule": crontab(minute=0, hour=2), # 2 AM
},
"send-daily-report": {
"task": "app.tasks.send_daily_report",
"schedule": crontab(minute=0), # every hour
},
}
app/tasks.py:
import logging
from datetime import datetime, timedelta
from .celery import celery_app
from .extensions import db
from .models.user import User
log = logging.getLogger(__name__)
@celery_app.task(name="app.tasks.cleanup_expired_tokens")
def cleanup_expired_tokens():
# Delete users who never verified within 7 days
cutoff = datetime.utcnow() - timedelta(days=7)
deleted = User.query.filter(
User.is_verified == False,
User.created_at < cutoff,
).delete()
db.session.commit()
log.info(f"cleaned up {deleted} unverified users")
return {"deleted": deleted}
@celery_app.task(name="app.tasks.send_daily_report", bind=True, max_retries=3)
def send_daily_report(self):
try:
total_users = User.query.count()
new_today = User.query.filter(
User.created_at >= datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
).count()
log.info(f"daily report: {total_users} total, {new_today} new today")
return {"total": total_users, "new_today": new_today}
except Exception as e:
log.exception("daily report failed")
raise self.retry(exc=e, countdown=60)
Migrations with Flask-Migrate #
# Init (once; already done in the project template)
docker compose exec api flask db init
# Create a migration after changing models
docker compose exec api flask db migrate -m "add user model"
# Apply the migration
docker compose exec api flask db upgrade
# Rollback
docker compose exec api flask db downgrade
flask db is the CLI from flask-migrate, a wrapper over Alembic. It detects model changes and generates migration files.
Build and Run #
# Build
docker compose build
# Run all services
docker compose up -d
# Run with the worker
docker compose --profile with-worker up -d
# View logs
docker compose logs -f api
# Flask shell
docker compose exec api flask shell
# View routes
docker compose exec api flask routes
# Stop
docker compose down
Access:
- API: http://localhost:5000
- Health: http://localhost:5000/healthz
- PostgreSQL:
localhost:5432 - Redis:
localhost:6379
Test:
# Register
curl -X POST http://localhost:5000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","username":"dedi","full_name":"Dedi","password":"password123"}'
# Login
curl -X POST http://localhost:5000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# Get the current user (using a token)
curl http://localhost:5000/api/v1/users/me \
-H "Authorization: Bearer your-token-here"
Testing with Pytest #
tests/conftest.py:
import pytest
from app import create_app
from app.extensions import db as _db
@pytest.fixture(scope="session")
def app():
app = create_app("testing")
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def db(app):
yield _db
_db.session.rollback()
tests/test_auth.py:
def test_register(client):
response = client.post("/api/v1/auth/register", json={
"email": "[email protected]",
"username": "testuser",
"full_name": "Test User",
"password": "password123",
})
assert response.status_code == 201
data = response.get_json()
assert data["email"] == "[email protected]"
assert "password" not in data
def test_login(client):
client.post("/api/v1/auth/register", json={
"email": "[email protected]",
"username": "testuser",
"full_name": "Test",
"password": "password123",
})
response = client.post("/api/v1/auth/login", json={
"email": "[email protected]",
"password": "password123",
})
assert response.status_code == 200
assert "access_token" in response.get_json()
def test_login_invalid(client):
response = client.post("/api/v1/auth/login", json={
"email": "[email protected]",
"password": "wrong",
})
assert response.status_code == 401
docker compose exec api pytest
When Flask Fits #
Use Flask if:
✓ Lightweight API microservices
✓ You need high flexibility (choose your own ORM, libraries, etc.)
✓ Small-to-mid-scale applications
✓ Simple web apps without complex ORM/admin needs
✓ The team likes simple, idiomatic Python
Avoid Flask if:
✗ You need an out-of-the-box ORM and admin (use Django)
✗ You need end-to-end type hints (use FastAPI)
✗ Complex traditional server-rendered apps
✗ Real-time apps (Flask-SocketIO is limited)
Flask fits API microservices, internal dashboards, and small-scale apps very well. For enterprise apps with admin, ORM, and complex form handling, Django is more appropriate. For modern type-hinted async APIs, FastAPI fits better.
Best Practices #
Application Factory #
Always use the application factory pattern. Separate app instance creation from configuration. Makes testing and multiple configs easy.
Split Config per Environment #
Use class-based config: BaseConfig, DevelopmentConfig, ProductionConfig, TestingConfig. Choose via the FLASK_ENV environment variable.
Use SQLAlchemy 2.0 Style #
SQLAlchemy 2.0 introduced the select() query style, which is more Pythonic, more type-safe, and easier to debug.
Service Layer #
Separate business logic from route handlers. The service layer handles validation and orchestration. Handlers only receive requests, call services, and return responses.
Healthchecks with DB Pings #
/healthz must check dependencies (database, cache). Return 503 when a dependency is down. /readyz checks whether the app is ready for requests.
Use Flask-Migrate for Schemas #
Don’t db.create_all() in production. Use Flask-Migrate (Alembic) for versioned schema migrations.
Bind-Mount Source Code in Dev #
Mount source code into the container. Flask debug mode auto-reloads on every save.
Use Gunicorn in Production #
The Flask development server is not for production. Use Gunicorn with appropriate workers (usually 2-4 × CPU cores).
Celery for Background Tasks #
Don’t process emails, reports, or heavy tasks in request handlers. Use Celery + Redis. The ContextTask pattern gives tasks access to the Flask app_context.
Troubleshooting #
Port 5000 Already in Use #
On macOS Catalina+, port 5000 is used by the AirPlay Receiver. Disable it in System Preferences or change the Flask port.
lsof -i :5000
# or disable the AirPlay Receiver
sudo killall AirPlayUIAgent
Import Errors After Adding Libraries #
Rebuild the image:
docker compose build api
docker compose up -d
Database Connection Refused #
Use depends_on: condition: service_healthy. Add retry logic or an entrypoint script.
Auto-Reload Not Working #
Make sure FLASK_DEBUG=1 is set. Check the logs for Python errors — sometimes an import error prevents the reload.
Werkzeug vs Production Servers #
Werkzeug (the Flask dev server) is only for development. For production, always use Gunicorn or uWSGI.
Summary #
- Flask is ideal for API microservices and small-to-mid-scale apps needing flexibility.
- The application factory is the idiomatic Flask pattern — separate app instance creation from configuration, making testing easier.
- Split config per environment:
BaseConfig,DevelopmentConfig,ProductionConfig,TestingConfig. Choose viaFLASK_ENV.- Multi-stage Dockerfiles for production: a builder installs dependencies, a slim runtime with Gunicorn.
- Dockerfile.dev for development: full Python + tools, auto-reload enabled.
- The service layer separates business logic from route handlers. Services validate, handlers orchestrate.
- SQLAlchemy 2.0 style queries:
select(),session.execute(). More type-safe and Pythonic.- Flask-Migrate for schema migrations. Generate in dev, apply in prod, always commit.
- Healthchecks check dependencies. Return 503 when the database is down.
- JWT for authentication:
flask-jwt-extendedwith access + refresh tokens. The@jwt_required()decorator.- Celery for background tasks: with a Redis broker, the Flask app context is injected via
ContextTask. Periodic tasks viabeat_schedule.- Bind-mount source code in dev. Auto-reload enabled.
- Use Gunicorn in production with appropriate workers. The Flask dev server is not for production.
- Best practices: application factory, split configs, service layer, healthchecks, migrations, Gunicorn.
- Alternatives: Django for a full framework, FastAPI for modern type-hinted APIs.
- Use Flask if you need flexibility and API microservices. Avoid it if you need an out-of-the-box ORM/admin (use Django).