Release v0.18.0 - The Pluggable Restructure
Release Notes by FastAPI-boilerplate team
This is the release we promised in v0.17.0; the one that tears the layout apart and rebuilds it as a real plugin system. If you've been pinned to v0.17.0 waiting for it, this is your moment.
A heads-up before we go further: the diff is enormous; v0.17.0 → v0.18.0 is not the kind of upgrade you run git pull for. The Python package moved, auth changed completely, workers changed, the admin panel changed, there's a new CLI in a new workspace member. We didn't do this lightly, and the rationale is the rest of this document.
Why this release is so different
We didn't iterate on the v0.17.0 codebase to get here. We rebased the project on the fastroai-template structure.
fastroai-template is the production-tested template we use internally (and sell) for AI SaaS products. It's been running real apps for months and the structural choices (three-layer architecture, vertical-slice modules, server-side sessions, SQLAdmin, Taskiq, swappable infrastructure) have proven themselves under load. Reinventing all of that for FastAPI-boilerplate would have meant another six months of polish; rebasing meant we could ship the good parts on day one.
The trade-off is that fastroai-template carries a lot of stuff this boilerplate's audience doesn't necessarily need: a Stripe integration, subscription/credits/entitlements modeling, AI agent orchestration, usage tracking with cost calculation, OAuth-specific provisioning for SaaS, an Astro frontend. Excellent for an AI SaaS starter, wrong for a general FastAPI boilerplate.
So this release is fastroai-template minus the SaaS/AI parts, plus a plugin system that fastroai-template doesn't have. What's left is the structural skeleton; the parts that any FastAPI app needs and that we've watched hold up in real use:
- The three-layer split (
interfaces/,infrastructure/,modules/) - Vertical-slice modules (
user/,tier/,api_keys/,rate_limit/) - Server-side sessions + CSRF, OAuth (Google wired, GitHub scaffolded)
- SQLAdmin, Taskiq, swappable cache/session/rate-limit backends
- The production security validator that refuses to boot with insecure defaults
The new part, the one fastroai-template doesn't have, is bp — a plugin-aware CLI. fastroai-template ships everything in-tree because that's appropriate for an AI SaaS starter. FastAPI-boilerplate's whole pitch is "use what you need, drop what you don't", and that pitch only works if dropping things and adding things are first-class operations. The bp.commands and bp.features entry points are how external Python packages contribute new commands and feature generators without touching the core. Build a Stripe plugin, a Prometheus plugin, a CRUD-generator plugin, they all ship as separate packages.
If you can stay on v0.17.0, consider it
For brand-new projects, v0.18.0 is the better starting point. For existing apps with significant custom code on v0.17.0, the honest answer is: the migration path is "copy your business logic into the new structure", not a sed-style find-and-replace. If your fork has diverged from v0.17.0 in non-trivial ways, pinning to v0.17.0 may be the right call.
We'll keep v0.17.0 around as a tag forever. You can hold there as long as you need.
New co-maintainers
Two contributors are now officially helping us maintain and improve the boilerplate going forward: @carlosplanchon and @emiliano-gandini-outeda. Both have been contributing for months — this release is the right moment to make it official.
@carlosplanchon drove a lot of the polish that made this restructure possible. Six merged PRs and four issues that shaped direction:
- #247 — Python 3.14 dependency compatibility pass
- #218 — README cleanup; installation scripts moved out of Gists into the repo
- #215 — FastCRUD 0.19.0 pagination structure adoption
- #214 — Docs note on falsy values from FastCRUD
- #205 — SQLAlchemy 2.0 syntax in
docs/user-guide/development.md - #192 — DeepWiki link in the README
- #221 — Proposal: Extension Mechanism (informed the shape of the
bp.commands/bp.featuresdesign that landed in this release) - #216 — Flagged that the README was too long; led to the slim version that shipped here
- #195 — Documentation Improvements (long-running tracking issue)
- #193 — Proposed the community Discord
@emiliano-gandini-outeda has been pushing on documentation quality:
If you've been wondering why the docs felt sharper in this release, that's part of where it came from.
What this means in practice: both can triage issues, request changes on PRs, and merge to main. If you've been opening issues hoping for a response, that response will get faster.
If you want to talk to us
Join us on Discord, open an issue, or ping @igorbenav, @LucasQR, @carlosplanchon, or @emiliano-gandini-outeda on the repo. The plugin system is intentionally minimal in v0.18.0 — bp.commands and bp.features cover most needs, but if you're hitting a wall (e.g., you want app-factory hooks, settings-mixin extensions, lifespan plugins), that's exactly the kind of feedback that lands in v0.19 instead of waiting for v1.0.
What's New in v0.18.0
This release contains eight breaking changes (called out individually below) and one major addition (the CLI). The themes:
- Three-layer architecture with vertical-slice modules
- uv workspace split —
backend/(deployable) +cli/(developer tool) bpCLI with plugin extension points —bp.commandsandbp.features- Server-side sessions + CSRF replaces JWT access/refresh tokens
- OAuth wired (Google end-to-end, GitHub scaffolded)
- API keys with scrypt + per-row salt + prefix-based lookup
- SQLAdmin replaces CRUDAdmin
- Taskiq replaces ARQ
- Swappable backends behind ABCs — Redis/Memcached/Memory for cache, sessions, rate limit
- Production security validator — startup gate that refuses insecure prod configs
- Full documentation rewrite + Zensical replaces MkDocs
- Lint enforcement —
PLC0415no-deferred-imports across the codebase
Breaking Changes Summary
| Change | Impact | Migration Effort |
|---|---|---|
src/app/ layout removed |
Imports break at import time | High — manual restructure |
| JWT auth removed (sessions only) | Authorization: Bearer clients rejected |
High — client-side cookie auth |
| CRUDAdmin → SQLAdmin | Custom admin views need porting | Medium |
| ARQ → Taskiq | Workers need re-registration | Medium |
| API key hash format | Existing keys won't validate | High — users must regenerate |
| Settings classes moved/composed | Env var names mostly stable; a few moved | Low |
cd backend && uv sync --extra dev |
Silently produces a broken venv | Low but easy to miss — use uv sync --all-packages --all-extras from repo root |
| Deployment scaffolder | ./setup.py local no longer exists |
Low — use bp deploy generate |
| Docs generator: MkDocs → Zensical | Local preview command + deploy workflow change | Low |
Detailed Changes
1. Three-Layer Architecture with Vertical-Slice Modules ⚠️ BREAKING
The old src/app/{admin,api,core,crud,middleware,models,schemas}/ layout was a horizontal slice per concern — every feature spread across seven directories. Adding a feature meant editing seven files; removing one (try removing posts from v0.17.0) meant playing whack-a-mole with imports.
The new layout splits by layer, then by vertical slice inside the domain layer:
backend/src/
├── interfaces/ # HTTP entry points: FastAPI app, routes, admin sub-app
├── infrastructure/ # Cross-cutting: cache, sessions, rate limit, taskiq, security, logging, db, config
└── modules/ # One directory per domain — owns its own models/schemas/crud/service/routes
├── user/
├── tier/
├── api_keys/
├── rate_limit/
└── common/
Each module owns its full vertical: models.py, schemas.py, crud.py, service.py, routes.py, enums.py. Removing a feature is rm -rf modules/foo, register your import-time guard in the v1 includes list, and you're done.
Import Migration
| Before (v0.17.0) | After (v0.18.0) |
|---|---|
from src.app.models.user import User |
from modules.user.models import User |
from src.app.schemas.user import UserCreate |
from modules.user.schemas import UserCreate |
from src.app.crud.crud_users import crud_users |
from modules.user.crud import crud_users |
from src.app.api.v1.users import router |
from modules.user.routes import router |
from src.app.core.config import settings |
from infrastructure.config.settings import get_settings |
from src.app.core.db.database import get_db |
from infrastructure.database.session import async_session |
from src.app.core.security import verify_password |
from infrastructure.auth.utils import verify_password |
No automated rewrite exists. If you have a fork with custom modules, you'll need to relocate them into backend/src/modules/<your_module>/ with the standard layout.
2. uv Workspace Split — backend/ and cli/ ⚠️ BREAKING
The repo root is now a uv workspace with two members:
fastapi-boilerplate/
├── pyproject.toml # Workspace root, not a shipped artifact
├── uv.lock # Single lockfile, shared
├── backend/
│ ├── pyproject.toml # name = "fastapi-boilerplate"
│ ├── Dockerfile # Multi-stage: dev / migrate / prod
│ └── src/
└── cli/
├── pyproject.toml # name = "fastapi-boilerplate-cli", [scripts] bp = "cli.app:app"
└── src/cli/
Workspace members share a single .venv at the repo root, but the prod Dockerfile only copies backend/src/ — the CLI does not ship to production. No Typer, no Jinja, no plugin discovery in the prod image.
The sync command changed. cd backend && uv sync --extra dev will silently produce a venv missing pytest/mypy/ruff because workspace member extras need --all-packages:
# Before (v0.17.0)
cd backend && uv sync --extra dev
# After (v0.18.0)
uv sync --all-packages --all-extras # from the repo rootTo install bp machine-wide outside the repo: uv tool install --editable ./cli.
3. bp CLI with Plugin Extension Points
This is the new piece — the one fastroai-template doesn't have. Two deliberately separate extension points:
bp.commands — external Python packages declare entry points pointing to a typer.Typer instance, mounted at the root: bp aws deploy ..., bp stripe webhooks ....
bp.features — external packages declare entry points pointing to a Feature instance with a manifest and a plan. bp feature list introspects them; bp feature apply <name> runs the plan with rollback on failure.
In-tree commands today: bp deploy generate {local,prod,nginx} (renders compose files), bp env gen-secret, bp env validate (runs the production security validator against current settings).
Plugin shape:
# myplugin/__init__.py
import typer
from cli.features.base import Feature, FeatureManifest, FeaturePlan, FileOp
# Command plugin
my_commands = typer.Typer(help="Stripe-related commands.")
@my_commands.command()
def webhooks():
"""List configured Stripe webhooks."""
...
# Feature plugin
class StripeFeature(Feature):
manifest = FeatureManifest(
name="stripe",
version="0.1.0",
summary="Scaffold a Stripe integration into a FastAPI-boilerplate project.",
)
def plan(self, project, **params) -> FeaturePlan:
return FeaturePlan(files=(FileOp(template="stripe.py.j2", target=project.modules_dir / "stripe" / "service.py"),))
# myplugin/pyproject.toml
# [project.entry-points."bp.commands"]
# stripe = "myplugin:my_commands"
# [project.entry-points."bp.features"]
# stripe = "myplugin:StripeFeature"A broken plugin must not break the CLI. Discovery wraps each EntryPoint.load() in a broad except and surfaces a warning — the working subset stays usable.
Docs: docs/cli/index.md, docs/cli/commands.md, docs/cli/plugins.md.
4. Server-Side Sessions + CSRF ⚠️ BREAKING
JWT access tokens, refresh tokens, and the token_blacklist table are gone. The new auth uses opaque session IDs in HttpOnly cookies, backed by Redis (default), Memcached, or in-memory storage behind AbstractSessionStorage.
Why we made the swap
JWT revocation through a blacklist is the standard JWT workaround and the standard JWT footgun: in practice, services either skip the blacklist check on hot paths (silently accepting revoked tokens) or pay a DB read per request. Server-side sessions don't have this problem — revocation is just a delete.
What changed
# Before (v0.17.0) — JWT in Authorization header
from app.core.security import oauth2_scheme
@router.get("/me")
async def me(token: str = Depends(oauth2_scheme)):
user = await decode_token(token)
return user
# After (v0.18.0) — session cookie, dependency-injected
from infrastructure.auth.session.dependencies import get_current_user
@router.get("/me")
async def me(user = Depends(get_current_user)):
return userCSRF is enforced by default (CSRF_ENABLED=true) for state-changing endpoints. Clients sending Authorization: Bearer <jwt> will be rejected — the API surface is cookie-based now.
Documentation: docs/user-guide/authentication/sessions.md (new).
5. OAuth Wired (Google + GitHub Scaffolded)
OAuth piggybacks on the session machinery. The Google provider is end-to-end (callback creates a session, sets the cookie); the GitHub provider has the same provider/factory shape — adding the credential pair lights it up. The provider abstraction makes adding Microsoft EntraID or any other OIDC provider a small addition (see infrastructure/auth/oauth/providers/google.py for the shape).
OAuth env vars:
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=
OAUTH_REDIRECT_BASE_URL=http://localhost:80006. API Keys with Scrypt + Per-Row Salt ⚠️ BREAKING
The new api_keys module is a developer-facing credential system — users create named API keys with permissions and usage limits, the service validates them on requests, per-call usage is recorded.
Credential storage went through several iterations driven by CodeQL alerts. Final state: scrypt with a per-row salt, stored as scrypt$N$r$p$salt_b64$derived_b64 with N=2**14, r=8, p=1, dklen=32. Per-row salt means key_hash is non-deterministic; lookup uses the indexed key_prefix column instead.
Breaking: any API keys hashed under any prior scheme will not validate. For brand-new boilerplate users this is fine. For anyone running an earlier version in production, invalidate keys and have users regenerate.
A specific bug worth flagging if you fork this for your own credential system: secrets.token_urlsafe() uses an alphabet that includes _. The naive prefix-extraction api_key.split("_", 2)[1] is wrong about 22% of the time. The fix is to extract by position (api_key[4:12]). Regression test in tests/unit/modules/api_keys/test_service.py::test_validate_api_key_with_underscore_in_prefix.
7. CRUDAdmin → SQLAdmin ⚠️ BREAKING
CRUDAdmin was the in-house admin we'd been maintaining. The reality is we don't have the time to make it good for now, so we kept SQLAdmin since it's good and battle-tested. We did have to add a DataclassModelMixin because SQLAdmin's default insert_model blows up on MappedAsDataclass models with required fields — the mixin creates the model with the data instead of empty-then-set.
Admin is env-toggled (ADMIN_ENABLED) so the /admin mount only happens when explicitly opted in.
# Before (v0.17.0)
CRUD_ADMIN_ENABLED=true
CRUD_ADMIN_SECRET_KEY=...
# After (v0.18.0)
ADMIN_ENABLED=true
# Uses main SECRET_KEY for sessions8. ARQ → Taskiq ⚠️ BREAKING
# Before (v0.17.0)
arq app.core.worker.WorkerSettings
# After (v0.18.0)
taskiq worker infrastructure.taskiq.worker:default_brokerBrokers are env-selectable: Redis (default) or RabbitMQ via taskiq-aio-pika. Tasks are registered through a small TaskRegistry for development visibility. The infra layer exposes a DBSession dependency so tasks use the same async session pattern as routes.
Task definition change
# Before (v0.17.0, ARQ)
async def send_email(ctx, recipient: str, subject: str): ...
# After (v0.18.0, Taskiq)
from infrastructure.taskiq import default_broker
from infrastructure.taskiq.deps import DBSession
@default_broker.task(task_name="send_email")
async def send_email(recipient: str, subject: str, db: DBSession): ...9. Swappable Backends Behind ABCs
Cache, rate limit, and session storage all share the same shape: abstract base in base.py, concrete backends in backends/{redis,memcached,memory}.py, factory in storage.py / provider.py. The boilerplate ships Redis as the default, Memcached as the alternate, in-memory for tests. Swapping is an env var.
SESSION_BACKEND=memcached
CACHE_BACKEND=memcached
RATE_LIMITER_BACKEND=memcachedA structural choice worth naming: the abstract base classes live in */base.py, not in the same module as the factory. When a factory imports concrete backends at module top-level and those backends import the abstract base, putting the base in the factory's module creates a cycle. Splitting cache/base.py, rate_limit/base.py, auth/session/base.py resolves it. (If you fork and add a new infrastructure type, follow the same pattern.)
10. Production Security Validator
infrastructure/security/production_validator.py is a startup gate that refuses to boot in production with insecure configuration. It checks SECRET_KEY strength against a default-value blocklist, DB credential strength, Redis config, CORS policy (no * with credentials), session cookie flags, admin password presence, debug mode, OpenAPI exposure, and CREATE_TABLES_ON_STARTUP (must be false in prod — Alembic should own the schema).
Critical errors raise ProductionSecurityError and stop the app; non-critical issues log warnings. bp env validate runs the same checks in production-mode against current settings regardless of the configured ENVIRONMENT, so dev/staging configs can be audited before promotion.
11. Documentation Rewrite + Zensical Replaces MkDocs
Every page under docs/user-guide/ was rewritten to match the new reality. Three new pages cover the CLI: docs/cli/index.md, docs/cli/commands.md, docs/cli/plugins.md. The README was slimmed but kept self-contained.
The docs site generator moved from MkDocs (mkdocs-material) to Zensical, configured via zensical.toml at the repo root. Local preview is uvx zensical serve. Build is uvx zensical build → ./site/. The mkdocs.yml file is gone.
Why we made the swap: waiting for MkDocs 2.0 wasn't going to happen on this PR's timeline, and Zensical has a more modern theme variant out of the box plus supports the triple-nested nav the user-guide subsections need without theme overrides.
Migration for fork maintainers
If you're maintaining a fork with custom docs:
# Before
uvx --with mkdocs-material mkdocs serve
uvx --with mkdocs-material mkdocs gh-deploy
# After
uvx zensical serve
uvx zensical build
# For GH Pages: see the docs deployment section below12. Lint Enforcement — PLC0415 (No Deferred Imports)
PLC0415 is now in extend-select for both backend/pyproject.toml and cli/pyproject.toml. All imports must be at the top of the file — no function-level deferred imports.
This caught a real circular-import issue during the rebase: when concrete backends import the abstract base from the same module as the factory, lifting the factory's backend imports to top-level surfaces the cycle. Resolved by extracting AbstractSessionStorage to auth/session/base.py.
Trade-offs accepted
bp env gen-secretimports the boilerplate's settings module on every invocation (was deferred). The cost is negligible and the rule is uniform.taskiq_aio_pikais now a hard import-time requirement ofinfrastructure/taskiq/brokers.py. Declared as a hard dep inbackend/pyproject.toml, so this matches reality.
Migration Guide
To repeat: this is not a git pull upgrade. The Python package moved, the import paths changed, auth changed, workers changed. Here's the honest path.
If you're starting fresh
git clone https://github.com/benavlabs/FastAPI-boilerplate
cd FastAPI-boilerplate
uv sync --all-packages --all-extras
uv run bp deploy generate local
docker compose up -dThat's the whole quickstart. There's no migration to do — you're on v0.18.0 from commit one.
If you have a fork on v0.17.0 with custom code
The realistic migration path is:
- Pin your existing app to
v0.17.0in your fork. It stays supported forever. - In a separate branch, start v0.18.0 fresh. Copy your custom modules into
backend/src/modules/<your_module>/with the standard vertical layout (models.py,schemas.py,crud.py,service.py,routes.py). - Rewrite auth integration. If you used JWT, switch to the session dependency pattern (
Depends(get_current_user)). If you have clients sendingAuthorization: Bearer, those clients need to switch to cookie-based auth or you need to add a custom token-issuing route in your fork. - Rewrite ARQ tasks as Taskiq tasks (see §8 above).
- Port custom admin views from CRUDAdmin to SQLAdmin's
ModelViewshape (theDataclassModelMixinreduces the boilerplate). - Update env vars:
CRUD_ADMIN_*→ADMIN_*(uses mainSECRET_KEYfor sessions)- JWT-related vars no longer used
SESSION_BACKEND,CSRF_ENABLEDare new
- Regenerate API keys if you have any (the hash format changed).
- Update sync command:
uv sync --all-packages --all-extrasfrom the repo root, notcd backend && uv sync --extra dev. - Update Docker compose: scrap the
scripts/{local_with_uvicorn,gunicorn_managing_uvicorn_workers,production_with_nginx}/directories and regenerate viauv run bp deploy generate {local,prod,nginx}. - Update docs deployment if you publish your own docs: see the next section.
Data preservation
- Database: the
user,tiertables are structurally similar. New tables (api_keys,key_permissions,key_usage,sessions) need an Alembic migration. Thetoken_blacklisttable can be dropped — nothing reads it anymore. - Redis volumes: nothing in the new code is incompatible with existing Redis data. Sessions will be re-created on next login.
Deploying docs with Zensical
You asked. Two parts: local preview and GitHub Pages deployment.
Local preview
uvx zensical serve
# Or, if you want the production build locally:
uvx zensical build
# Site is in ./site/ — open ./site/index.html or serve itGitHub Pages deployment
We don't ship a docs-deploy workflow in this release (the previous mkdocs gh-deploy is gone with mkdocs.yml). The simplest setup is a GitHub Actions workflow that builds the site and uploads it to GitHub Pages via the official Pages action. Save as .github/workflows/docs.yml:
name: Deploy docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install uv
- name: Build docs
run: uvx zensical build
- uses: actions/upload-pages-artifact@v3
with:
path: ./site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4In repo settings: Settings → Pages → Source set to GitHub Actions (not "Deploy from a branch").
That replaces the old mkdocs gh-deploy flow. First push to main triggers a deploy; the site becomes available at the URL declared in zensical.toml (site_url).
What's Changed
- feat: rebase project on fastroai-template structure (#255) by @igorbenav — the restructure itself
- security: bump idna+sqladmin, drop unused fastsecure (#256) by @igorbenav — removes
python-jose/ecdsaMinerva timing vuln,idnaCVE-2024-3651 bypass,sqladminajax_lookup auth bypass - ci: pin workflow permissions (#257) by @igorbenav — adds explicit least-privilege
permissions: contents: readblocks to all CI workflows
Eight months of stale code paths (src/app/) are gone. ~24,000 lines added, ~4,000 removed across 240+ files. Detailed per-area changes are documented in this file and in pr.md / pr2.md at the repo root for historical reference.
Full Changelog: v0.17.0...v0.18.0