feat: production docker + documentation

- Backend Dockerfile: multi-stage build with venv, gunicorn+uvicorn workers, entrypoint runs alembic then gunicorn
- Frontend Dockerfile: multi-stage with npm ci, nginx:1.27-alpine runtime
- nginx.conf: gzip compression, security headers (X-Frame-Options, X-Content-Type-Options, etc.), static asset caching, correct API proxy preserving /api/ prefix
- docker-compose.yml: production config — db healthcheck, backend healthcheck, frontend depends_on backend healthy, no exposed backend port
- docker-compose.override.yml: dev hot-reload — uvicorn --reload with source mount, npm run dev on port 5173
- Rate limiting: slowapi middleware on /auth/login (10/min) and /auth/register (5/min)
- README.md: full documentation with architecture, quick start, API table, dev/prod instructions, tests
- .env.example: all variables documented with comments
- .gitignore: extended with *.pyc, *.cover, .ruff_cache, frontend/.vite, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nox (OpenClaw)
2026-03-17 17:07:38 +00:00
parent e3fac99045
commit 434de9aa3e
13 changed files with 315 additions and 60 deletions
+17 -8
View File
@@ -1,17 +1,26 @@
# ── PostgreSQL ──────────────────────────────────────────
# ── PostgreSQL ──────────────────────────────────────────────────────────────
POSTGRES_USER=budget
POSTGRES_PASSWORD=budget
POSTGRES_DB=budget_tracker
DB_PORT=5432
# ── Backend (FastAPI) ──────────────────────────────────
# ── Backend (FastAPI) ───────────────────────────────────────────────────────
# Full connection URL — built from the vars above by docker-compose
DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker
# IMPORTANT: generate a strong random key for production
# python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
DEBUG=false
BACKEND_PORT=8000
# ── Frontend ───────────────────────────────────────────
FRONTEND_PORT=3000
# Comma-separated list of allowed origins (JSON array syntax)
# Production: set to your actual frontend domain, e.g. ["https://budget.example.com"]
CORS_ORIGINS=["http://localhost"]
# ── Ports (override for local conflicts) ────────────────────────────────────
# Production: frontend is exposed on port 80 (FRONTEND_PORT)
FRONTEND_PORT=80
# Dev only:
DB_PORT=5432
BACKEND_PORT=8000
+19 -6
View File
@@ -1,32 +1,45 @@
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.egg-info/
dist/
.venv/
venv/
*.cover
.coverage
htmlcov/
.pytest_cache/
.ruff_cache/
*.egg
# Node
node_modules/
frontend/dist/
frontend/.vite/
*.tsbuildinfo
# Environment
# Environment — never commit secrets
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS
.DS_Store
Thumbs.db
desktop.ini
# Docker
pgdata/
# Testing
.coverage
htmlcov/
.pytest_cache/
# Build artifacts
dist/
build/
+139 -21
View File
@@ -1,37 +1,155 @@
# Budget Tracker
Application web de suivi de budget personnel.
A personal finance web application to track income, expenses, and budgets with analytics and CSV/PDF export.
## Stack technique
<!-- Screenshots placeholder -->
<!-- ![Dashboard](docs/screenshots/dashboard.png) -->
- **Backend** : FastAPI, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16
- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui
- **Graphiques** : Recharts
- **Export** : CSV, PDF (WeasyPrint)
---
## Démarrage rapide
## Stack
| Layer | Technology |
|-----------|-----------------------------------------------------|
| Backend | FastAPI 0.115, SQLAlchemy 2.0 async, Alembic |
| Database | PostgreSQL 16 |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, Radix UI |
| Charts | Recharts |
| Export | CSV (native), PDF (WeasyPrint) |
| Server | Gunicorn + Uvicorn workers, Nginx 1.27 |
---
## Quick Start
```bash
# Copier et configurer les variables d'environnement
# 1. Copy and configure environment
cp .env.example .env
# Lancer tous les services (dev avec hot-reload)
docker compose up
# 2. Start all services (production mode)
docker compose up -d
# Backend : http://localhost:8000
# Frontend : http://localhost:5173
# Health check : http://localhost:8000/health
# App is available at http://localhost
# API docs at http://localhost/api/v1/docs
```
## Développement
> The backend automatically runs `alembic upgrade head` on startup.
---
## Architecture
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Browser │──80──▶│ Nginx (frontend) │──/api/▶│ FastAPI │
└─────────────┘ │ serves SPA dist │ │ (gunicorn) │
└──────────────────┘ └──────┬───────┘
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
- **Frontend**: React SPA served by Nginx. All `/api/*` requests are proxied to the backend.
- **Backend**: FastAPI with async SQLAlchemy. Exposes REST API under `/api/v1`.
- **Database**: PostgreSQL with Alembic migrations applied at container start.
---
## API Endpoints
Interactive docs (Swagger UI): `http://localhost/api/v1/docs`
| Module | Prefix | Description |
|--------------|-------------------------|------------------------------------|
| Auth | `/api/v1/auth` | Register, login, refresh, logout |
| Transactions | `/api/v1/transactions` | CRUD + pagination + filters |
| Categories | `/api/v1/categories` | Income / expense categories |
| Budgets | `/api/v1/budgets` | Monthly budgets + rollover |
| Dashboard | `/api/v1/dashboard` | Monthly summary + charts data |
| History | `/api/v1/history` | Year-over-year analytics |
| Export | `/api/v1/export` | CSV and PDF export |
| Health | `/health` | Liveness probe |
---
## Development (hot-reload)
The `docker-compose.override.yml` is automatically merged in dev, enabling:
- Backend: `uvicorn --reload` with source code mounted
- Frontend: `npm run dev` (Vite HMR) on port 5173
```bash
# Linting backend
cd backend && ruff check .
# Start dev environment
cp .env.example .env
docker compose up
# Tests backend
cd backend && pytest
# Format frontend
cd frontend && npm run format
# Backend API: http://localhost:8000
# Frontend dev server: http://localhost:5173
# API docs: http://localhost:8000/api/v1/docs
```
---
## Production
```bash
# Use only the base compose file (no override)
docker compose -f docker-compose.yml up -d
# Generate a secure SECRET_KEY
python -c "import secrets; print(secrets.token_hex(32))"
# Set in .env:
# SECRET_KEY=<generated value>
# CORS_ORIGINS=["https://your-domain.com"]
```
---
## Tests
```bash
# Backend tests
cd backend
pip install -r requirements.txt
pytest
# With coverage
pytest --cov=app --cov-report=term-missing
# Frontend type check
cd frontend
npm run build # tsc -b + vite build
```
---
## Project Structure
```
budget-tracker/
├── backend/
│ ├── app/
│ │ ├── auth/ # JWT auth (register, login, refresh, logout)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── routers/ # FastAPI routers (transactions, budgets, …)
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # Business logic layer
│ │ ├── config.py # Settings (pydantic-settings)
│ │ ├── database.py # Async engine + session factory
│ │ └── main.py # App factory + middleware
│ ├── alembic/ # Database migrations
│ ├── tests/ # pytest test suite
│ ├── Dockerfile # Multi-stage build (venv → slim runtime)
│ └── entrypoint.sh # alembic upgrade head → gunicorn
└── frontend/
├── src/
│ ├── api/ # Axios client + API functions
│ ├── components/ # Shared UI components
│ ├── hooks/ # React Query hooks
│ ├── pages/ # Route-level page components
│ └── stores/ # Zustand auth store
├── Dockerfile # Multi-stage build (npm ci → nginx)
└── nginx.conf # Gzip, security headers, SPA fallback, API proxy
```
+32 -3
View File
@@ -1,13 +1,19 @@
FROM python:3.12-slim AS base
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM python:3.12-slim AS build
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps for WeasyPrint
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (build time)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
@@ -19,8 +25,31 @@ RUN apt-get update && \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (runtime)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libcairo2 \
libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["/entrypoint.sh"]
+6 -1
View File
@@ -1,13 +1,14 @@
import uuid
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.limiter import limiter
from app.auth.security import (
create_access_token,
create_refresh_token,
@@ -28,7 +29,9 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(
request: Request,
user_data: UserCreate,
session: AsyncSession = Depends(get_session),
) -> UserResponse:
@@ -54,7 +57,9 @@ async def register(
@router.post("/login", response_model=Token)
@limiter.limit("10/minute")
async def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
session: AsyncSession = Depends(get_session),
) -> Token:
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+8
View File
@@ -3,10 +3,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.database import engine
from app.limiter import limiter
from app.routers.budgets import router as budgets_router
from app.routers.categories import router as categories_router
from app.routers.dashboard import router as dashboard_router
@@ -28,6 +32,10 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting server..."
exec gunicorn app.main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile - \
--error-logfile -
+2
View File
@@ -12,6 +12,8 @@ bcrypt==4.2.1
python-multipart==0.0.20
httpx==0.28.1
weasyprint==63.1
gunicorn==23.0.0
slowapi==0.1.9
# Test dependencies
aiosqlite==0.20.0
pytest==8.3.4
+10 -3
View File
@@ -1,13 +1,18 @@
services:
db:
ports:
- "${DB_PORT:-5432}:5432"
backend:
build:
context: ./backend
dockerfile: Dockerfile
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./backend:/app
ports:
- "${BACKEND_PORT:-8000}:8000"
environment:
SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production}
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]'
frontend:
image: node:20-alpine
@@ -20,6 +25,8 @@ services:
- "5173:5173"
environment:
NODE_ENV: development
depends_on:
- backend
volumes:
frontend_node_modules:
+16 -10
View File
@@ -6,15 +6,14 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-budget}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"]
interval: 5s
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
backend:
build:
@@ -24,13 +23,19 @@ services:
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]'
DEBUG: ${DEBUG:-false}
ports:
- "${BACKEND_PORT:-8000}:8000"
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]}
DEBUG: "false"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
frontend:
build:
@@ -38,9 +43,10 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:80"
- "${FRONTEND_PORT:-80}:80"
depends_on:
- backend
backend:
condition: service_healthy
volumes:
pgdata:
+7 -2
View File
@@ -1,15 +1,20 @@
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+41 -6
View File
@@ -3,13 +3,48 @@ server {
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Static assets — long-lived cache with immutable flag
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy → backend (preserve full /api/ prefix)
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
# SPA fallback — all unknown routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}