Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
* Redis or Memcached caching (`@cache` decorator + provider API)
* **Plugin-ready `bp` CLI** - generate compose files, audit env, mount third-party command/feature plugins
* Docker Compose for local / prod / nginx-fronted (generated by the CLI)
* Runs on any Postgres - the bundled container, or serverless via [Neon](https://neon.com) ([guide](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/neon/))

## Why and When to use it

Expand All @@ -64,7 +65,7 @@

* **App**: FastAPI [app factory](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/project-structure/), env-aware docs exposure
* **Auth**: [server-side sessions](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/authentication/sessions/), CSRF, [OAuth](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/authentication/), [API keys](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/authentication/permissions/)
* **DB**: Postgres + SQLAlchemy 2.0, [Alembic migrations](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/migrations/) with prod-confirm gate
* **DB**: Postgres + SQLAlchemy 2.0, [Alembic migrations](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/migrations/) with prod-confirm gate - local container or serverless ([Neon](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/neon/))
* **CRUD**: [FastCRUD generics](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/crud/)
* **Caching**: [decorator + provider API](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/caching/) (Redis or Memcached)
* **Queues**: [Taskiq workers](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/background-tasks/) (Redis or RabbitMQ)
Expand Down Expand Up @@ -150,7 +151,7 @@ docker compose up --build
# → http://127.0.0.1:8000 (Swagger at /docs)
```

**Without Docker** (Postgres + Redis required locally):
**Without Docker** (Postgres + Redis required locally - or skip local Postgres with [Neon](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/neon/)):

```bash
cd backend
Expand Down Expand Up @@ -183,6 +184,20 @@ uv tool install --editable ./cli

More examples (superuser creation, tiers, rate limits, admin usage, plugin authoring) in the [docs](https://benavlabs.github.io/FastAPI-boilerplate/).

## Sponsors

<a href="https://neon.com"><img src="https://img.shields.io/badge/Neon-00E599?style=for-the-badge&logo=postgresql&logoColor=black" alt="Neon"></a>

**[Neon](https://neon.com)** supports this project with database credits for our open-source infrastructure - thank you. Neon is serverless Postgres: compute scales to zero when idle, and you can branch a database like you branch code (handy for per-PR preview environments).

It's also a drop-in option for your own build, and **free to start** - the free plan is permanent rather than a trial (no credit card), with enough storage and compute for dev, staging, and small production workloads. Point `DATABASE_URL` at a Neon project and the local Postgres container becomes optional - no code changes:

```env
DATABASE_URL=postgresql+asyncpg://user:password@ep-xxx-pooler.region.aws.neon.tech/neondb?ssl=require
```

→ [Full Neon guide](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/neon/) (connection-string conversion, pooled vs. direct endpoints, scale-to-zero pool settings). Any other managed Postgres works the same way.

## Contributing

Read [contributing](CONTRIBUTING.md).
Expand Down
15 changes: 15 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ POSTGRES_SYNC_PREFIX=postgresql://
POSTGRES_ASYNC_PREFIX=postgresql+asyncpg://
CREATE_TABLES_ON_STARTUP=true

# Hosted / serverless Postgres (Neon, RDS, Cloud SQL, ...): DATABASE_URL overrides
# every POSTGRES_* value above. Mind the asyncpg spelling — the driver prefix is
# postgresql+asyncpg:// and TLS is ssl=require, NOT libpq's sslmode=require.
# Guide: https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/neon/
# DATABASE_URL=postgresql+asyncpg://user:password@host.example.com/dbname?ssl=require

# Connection pool. POSTGRES_POOL_PRE_PING tests a connection before handing it out,
# so connections dropped by an idle timeout or a suspended serverless compute are
# replaced instead of failing a request. POSTGRES_POOL_RECYCLE discards connections
# older than N seconds (-1 disables); set it below your provider's idle timeout.
POSTGRES_POOL_SIZE=20
POSTGRES_MAX_OVERFLOW=0
POSTGRES_POOL_PRE_PING=true
POSTGRES_POOL_RECYCLE=-1

# ===================================
# Cache Configuration
# ===================================
Expand Down
15 changes: 12 additions & 3 deletions backend/src/infrastructure/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
from enum import StrEnum

from pydantic import Field
from pydantic_settings import BaseSettings
from starlette.config import Config

Expand Down Expand Up @@ -53,6 +54,15 @@ class DatabaseSettings(BaseSettings):

POSTGRES_POOL_SIZE: int = config("POSTGRES_POOL_SIZE", default=20, cast=int)
POSTGRES_MAX_OVERFLOW: int = config("POSTGRES_MAX_OVERFLOW", default=0, cast=int)
POSTGRES_POOL_PRE_PING: bool = config("POSTGRES_POOL_PRE_PING", default=True, cast=bool)
POSTGRES_POOL_RECYCLE: int = config("POSTGRES_POOL_RECYCLE", default=-1, cast=int)

# A field rather than a lookup inside DATABASE_URL, so callers can tell an
# explicit URL apart from one built out of the POSTGRES_* parts.
DATABASE_URL_OVERRIDE: str | None = Field(
default=config("DATABASE_URL", default=None),
validation_alias="DATABASE_URL",
)

@property
def DATABASE_URL(self) -> str:
Expand All @@ -61,9 +71,8 @@ def DATABASE_URL(self) -> str:
Checks for DATABASE_URL environment variable first (production pattern),
then falls back to constructing from individual components (development pattern).
"""
direct_url = config("DATABASE_URL", default=None)
if direct_url:
return direct_url
if self.DATABASE_URL_OVERRIDE:
return self.DATABASE_URL_OVERRIDE

return (
f"{self.POSTGRES_ASYNC_PREFIX}{self.POSTGRES_USER}:"
Expand Down
2 changes: 2 additions & 0 deletions backend/src/infrastructure/database/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
future=True,
pool_size=settings.POSTGRES_POOL_SIZE,
max_overflow=settings.POSTGRES_MAX_OVERFLOW,
pool_pre_ping=settings.POSTGRES_POOL_PRE_PING,
pool_recycle=settings.POSTGRES_POOL_RECYCLE,
)

local_session = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
Expand Down
68 changes: 65 additions & 3 deletions backend/src/infrastructure/security/production_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import re
from urllib.parse import unquote, urlsplit

from ..config.settings import EnvironmentOption, Settings
from ..logging import get_logger
Expand Down Expand Up @@ -187,7 +188,7 @@ def _validate_critical_security(self) -> list[str]:

if self._is_database_using_default_credentials():
errors.append(
"Database is using default credentials (POSTGRES_PASSWORD='postgres'). "
"Database is using default credentials (password 'postgres'). "
"This is a well-known default that attackers will try first. "
"Use a strong, unique password for production."
)
Expand Down Expand Up @@ -232,6 +233,13 @@ def _validate_warning_security(self) -> None:
redis_warnings = self._check_redis_security()
warnings.extend(redis_warnings)

if self._is_database_url_without_password():
warnings.append(
"DATABASE_URL is set but contains no password. This is expected with "
"IAM or certificate-based authentication, but is a mistake otherwise — "
"confirm the database is not reachable without credentials."
)

if self._is_cors_too_permissive():
warnings.append(
"CORS_ORIGINS is set to '*' (allow all origins). This can enable "
Expand Down Expand Up @@ -358,6 +366,42 @@ def _is_admin_access_completely_open(self) -> bool:
"""
return False

@staticmethod
def _password_from_url(url: str) -> str | None:
"""Extract the password component of a database URL.

Args:
url: A database URL, with or without credentials.

Returns:
The decoded password, or None if the URL carries none or cannot be parsed.
"""
try:
password = urlsplit(url).password
except ValueError:
return None

return unquote(password) if password is not None else None

def _effective_database_password(self) -> str | None:
"""Get the password the application will actually connect with.

A `DATABASE_URL` in the environment overrides every `POSTGRES_*` setting,
so a deployment against a managed provider (Neon, RDS, Cloud SQL) carries
its real credentials in that URL while `POSTGRES_PASSWORD` keeps its
default. Reading `POSTGRES_PASSWORD` alone would flag such a deployment as
insecure and refuse to start.

Returns:
The password embedded in `DATABASE_URL` when one is set, otherwise
`POSTGRES_PASSWORD`. None means an explicit URL was given but carries
no password at all, or could not be parsed.
"""
if not self.settings.DATABASE_URL_OVERRIDE:
return self.settings.POSTGRES_PASSWORD

return self._password_from_url(self.settings.DATABASE_URL_OVERRIDE)

def _is_database_using_default_credentials(self) -> bool:
"""Check if database is using well-known default credentials.

Expand All @@ -369,7 +413,7 @@ def _is_database_using_default_credentials(self) -> bool:
commonly targeted by attackers. Production systems should
use strong, unique passwords.
"""
return self.settings.POSTGRES_PASSWORD == "postgres"
return self._effective_database_password() == "postgres"

def _is_database_password_empty(self) -> bool:
"""Check if database password is empty or missing.
Expand All @@ -380,8 +424,26 @@ def _is_database_password_empty(self) -> bool:
Note:
Empty database passwords leave the database completely
unprotected and accessible to anyone who can reach it.

A `DATABASE_URL` with no password at all is not treated as an error —
authentication may be handled outside the connection string (IAM,
client certificates, a trusted socket). That case is warned about
instead, since it cannot be verified from here.
"""
password = self._effective_database_password()
if password is None:
return False

return not password or password.strip() == ""

def _is_database_url_without_password(self) -> bool:
"""Check if an explicit DATABASE_URL carries no password.

Returns:
True if `DATABASE_URL` is set but has no password component,
False otherwise.
"""
return not self.settings.POSTGRES_PASSWORD or self.settings.POSTGRES_PASSWORD.strip() == ""
return bool(self.settings.DATABASE_URL_OVERRIDE) and self._effective_database_password() is None

def _check_redis_security(self) -> list[str]:
"""Check Redis security configuration for all Redis instances.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def create_mock_settings(self, **overrides):
"ENVIRONMENT": EnvironmentOption.PRODUCTION,
"SECRET_KEY": "xF9mWqP3nL7vBfKsRt8HjZ2CyE5QaM6NuV4DgX1SpY7LwB9KzT3RhI0UoJ5PcA2MvS8",
"POSTGRES_PASSWORD": "secure_db_password",
"DATABASE_URL_OVERRIDE": None,
"REDIS_PASSWORD": "secure_redis_password",
"CACHE_BACKEND": "memcached",
"RATE_LIMITER_BACKEND": "memcached",
Expand Down Expand Up @@ -135,6 +136,64 @@ def test_empty_database_password_raises_error(self):

assert "Database password is empty" in str(exc_info.value)

def test_database_url_password_overrides_postgres_password(self):
"""Test that credentials in DATABASE_URL are what gets validated.

A managed provider (Neon, RDS, Cloud SQL) carries its credentials in
DATABASE_URL while POSTGRES_PASSWORD keeps its default — that must not
be reported as insecure.
"""
settings = self.create_mock_settings(
POSTGRES_PASSWORD="postgres",
DATABASE_URL_OVERRIDE="postgresql+asyncpg://db_user:not_a_real_password@db.example.com:5432/app?ssl=require",
)
validator = ProductionSecurityValidator(settings)

validator.validate_production_security()

def test_default_password_in_database_url_raises_error(self):
"""Test that a default password inside DATABASE_URL is still caught."""
settings = self.create_mock_settings(
POSTGRES_PASSWORD="secure_db_password",
DATABASE_URL_OVERRIDE="postgresql+asyncpg://postgres:postgres@db.example.com:5432/app",
)
validator = ProductionSecurityValidator(settings)

with pytest.raises(ProductionSecurityError) as exc_info:
validator.validate_production_security()

assert "default credentials" in str(exc_info.value)

def test_percent_encoded_password_in_database_url_is_decoded(self):
"""Test that a percent-encoded default password is decoded before checking."""
settings = self.create_mock_settings(
DATABASE_URL_OVERRIDE="postgresql+asyncpg://postgres:postgre%73@db.example.com:5432/app",
)
validator = ProductionSecurityValidator(settings)

with pytest.raises(ProductionSecurityError) as exc_info:
validator.validate_production_security()

assert "default credentials" in str(exc_info.value)

def test_database_url_without_password_warns_instead_of_failing(self, caplog):
"""Test that a passwordless DATABASE_URL warns but still starts.

Authentication may be handled outside the connection string (IAM,
client certificates, a trusted socket), which cannot be verified here.
"""
settings = self.create_mock_settings(
POSTGRES_PASSWORD="postgres",
DATABASE_URL_OVERRIDE="postgresql+asyncpg://app_user@db.example.com:5432/app",
)
validator = ProductionSecurityValidator(settings)

validator.validate_production_security()

warning_logs = [record for record in caplog.records if record.levelname == "WARNING"]
password_warnings = [log for log in warning_logs if "DATABASE_URL is set but contains no password" in log.message]
assert len(password_warnings) > 0

def test_multiple_critical_errors_combined(self):
"""Test that multiple critical errors are combined in one message."""
settings = self.create_mock_settings(SECRET_KEY="insecure", POSTGRES_PASSWORD="postgres")
Expand Down
10 changes: 10 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ ADMIN_PASSWORD=your-secure-password

See [Environment Variables](../user-guide/configuration/environment-variables.md) for the complete reference.

### Using a hosted database instead

You don't have to run Postgres yourself. Set `DATABASE_URL` and it overrides every `POSTGRES_*` variable above — the local container (or local install) becomes unnecessary:

```env
DATABASE_URL=postgresql+asyncpg://user:password@host.example.com/dbname?ssl=require
```

[Neon](../user-guide/database/neon.md) — serverless Postgres, and a sponsor of this project — is free to start (a permanent free plan, no credit card) and has a step-by-step guide here, including the connection-string conversion asyncpg needs. Any managed Postgres works the same way.

## Verification

After installing, verify everything works:
Expand Down
6 changes: 6 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ This boilerplate leverages cutting-edge Python technologies:
- **[Taskiq](https://taskiq-python.github.io/)** - Async-first task queue with Redis/RabbitMQ brokers
- **[Docker](https://docs.docker.com/compose/)** - Containerization for easy deployment

Postgres is the only hard requirement of those you have to provide — run the bundled container, or skip it entirely and point `DATABASE_URL` at a hosted provider. **[Neon](user-guide/database/neon.md)** (serverless Postgres, permanent free plan, database branching) is documented end to end.

## Key Features

### Performance & Scalability
Expand Down Expand Up @@ -129,6 +131,10 @@ Fastro is the free **foundation**. **[FastroAI](https://fastro.ai)** builds on t
</a>
</p>

## Sponsors

**[Neon](https://neon.com)** supports this project with database credits for our open-source infrastructure - thank you. Neon is serverless Postgres with a permanent free plan, and a drop-in option for your own build: see the **[Neon guide](user-guide/database/neon.md)**.

## Community & Support

- **[Discord Community](community.md)** - Join our Discord server to connect with other developers
Expand Down
10 changes: 9 additions & 1 deletion docs/user-guide/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ CREATE_TABLES_ON_STARTUP=true
| `CREATE_TABLES_ON_STARTUP` | `true` | Auto-create tables from models on startup |
| `POSTGRES_POOL_SIZE` | `20` | SQLAlchemy connection pool size |
| `POSTGRES_MAX_OVERFLOW` | `0` | Pool overflow connections |
| `POSTGRES_POOL_PRE_PING` | `true` | Test a pooled connection before use, replacing ones the server has dropped |
| `POSTGRES_POOL_RECYCLE` | `-1` | Discard connections older than N seconds (`-1` disables) |

If you set `DATABASE_URL` directly, it overrides the constructed URL.
If you set `DATABASE_URL` directly, it overrides the constructed URL — use it whenever the connection needs more than host/port/credentials, such as a managed provider that requires TLS:

```env
DATABASE_URL=postgresql+asyncpg://user:password@host.example.com/dbname?ssl=require
```

The URL must use the `postgresql+asyncpg://` prefix, and query parameters are passed to `asyncpg` (which spells TLS `ssl=require`, not libpq's `sslmode=require`). The [production validator](../production.md#the-production-validator) reads the credentials out of the URL, so the `POSTGRES_*` variables can keep their defaults. See [Neon](../database/neon.md) for a full walkthrough with a serverless provider.

## Cache

Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/database/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Learn how to work with the database layer in the FastAPI Boilerplate. This secti
- **[Schemas](schemas.md)** - Validate and serialize data with Pydantic
- **[CRUD Operations](crud.md)** - Database access via FastCRUD
- **[Migrations](migrations.md)** - Manage schema changes with Alembic
- **[Neon](neon.md)** - Run on serverless Postgres instead of a local container

## Quick Overview

Expand Down
Loading
Loading