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:
+17
-8
@@ -1,17 +1,26 @@
|
|||||||
# ── PostgreSQL ──────────────────────────────────────────
|
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||||
POSTGRES_USER=budget
|
POSTGRES_USER=budget
|
||||||
POSTGRES_PASSWORD=budget
|
POSTGRES_PASSWORD=budget
|
||||||
POSTGRES_DB=budget_tracker
|
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
|
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
|
SECRET_KEY=change-me-in-production
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
||||||
DEBUG=false
|
|
||||||
BACKEND_PORT=8000
|
|
||||||
|
|
||||||
# ── Frontend ───────────────────────────────────────────
|
# Comma-separated list of allowed origins (JSON array syntax)
|
||||||
FRONTEND_PORT=3000
|
# 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
@@ -1,32 +1,45 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
*.cover
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Environment
|
# Environment — never commit secrets
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# Testing
|
# Build artifacts
|
||||||
.coverage
|
dist/
|
||||||
htmlcov/
|
build/
|
||||||
.pytest_cache/
|
|
||||||
|
|||||||
@@ -1,37 +1,155 @@
|
|||||||
# Budget Tracker
|
# 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 -->
|
||||||
|
<!--  -->
|
||||||
|
|
||||||
- **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
|
```bash
|
||||||
# Copier et configurer les variables d'environnement
|
# 1. Copy and configure environment
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Lancer tous les services (dev avec hot-reload)
|
# 2. Start all services (production mode)
|
||||||
docker compose up
|
docker compose up -d
|
||||||
|
|
||||||
# Backend : http://localhost:8000
|
# App is available at http://localhost
|
||||||
# Frontend : http://localhost:5173
|
# API docs at http://localhost/api/v1/docs
|
||||||
# Health check : http://localhost:8000/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Linting backend
|
# Start dev environment
|
||||||
cd backend && ruff check .
|
cp .env.example .env
|
||||||
|
docker compose up
|
||||||
|
|
||||||
# Tests backend
|
# Backend API: http://localhost:8000
|
||||||
cd backend && pytest
|
# Frontend dev server: http://localhost:5173
|
||||||
|
# API docs: http://localhost:8000/api/v1/docs
|
||||||
# Format frontend
|
```
|
||||||
cd frontend && npm run format
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
@@ -1,13 +1,19 @@
|
|||||||
FROM python:3.12-slim AS base
|
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
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 && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
libpango-1.0-0 \
|
libpango-1.0-0 \
|
||||||
libpangocairo-1.0-0 \
|
libpangocairo-1.0-0 \
|
||||||
libgdk-pixbuf2.0-0 \
|
libgdk-pixbuf2.0-0 \
|
||||||
@@ -19,8 +25,31 @@ RUN apt-get update && \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 . .
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth.dependencies import get_current_user
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.limiter import limiter
|
||||||
from app.auth.security import (
|
from app.auth.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_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)
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def register(
|
async def register(
|
||||||
|
request: Request,
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
@@ -54,7 +57,9 @@ async def register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
async def login(
|
async def login(
|
||||||
|
request: Request,
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Token:
|
) -> Token:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -3,10 +3,14 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.auth.router import router as auth_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
|
from app.limiter import limiter
|
||||||
from app.routers.budgets import router as budgets_router
|
from app.routers.budgets import router as budgets_router
|
||||||
from app.routers.categories import router as categories_router
|
from app.routers.categories import router as categories_router
|
||||||
from app.routers.dashboard import router as dashboard_router
|
from app.routers.dashboard import router as dashboard_router
|
||||||
@@ -28,6 +32,10 @@ def create_app() -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
|||||||
@@ -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 -
|
||||||
@@ -12,6 +12,8 @@ bcrypt==4.2.1
|
|||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
weasyprint==63.1
|
weasyprint==63.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
slowapi==0.1.9
|
||||||
# Test dependencies
|
# Test dependencies
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production}
|
||||||
DEBUG: "true"
|
DEBUG: "true"
|
||||||
|
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
@@ -20,6 +25,8 @@ services:
|
|||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
|
|||||||
+16
-10
@@ -6,15 +6,14 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-budget}
|
POSTGRES_USER: ${POSTGRES_USER:-budget}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
|
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
|
||||||
ports:
|
|
||||||
- "${DB_PORT:-5432}:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -24,13 +23,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]'
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||||
DEBUG: ${DEBUG:-false}
|
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||||
ports:
|
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]}
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
DEBUG: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -38,9 +43,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-80}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
+7
-2
@@ -1,15 +1,20 @@
|
|||||||
|
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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 --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+41
-6
@@ -3,13 +3,48 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user