Construye una API completa con FastAPI, PostgreSQL y Docker

Construye una API completa con FastAPI, PostgreSQL y Docker

Tutorial completo para crear una API REST de producción con FastAPI, PostgreSQL y Docker


Meta Title: Crear API REST con FastAPI, PostgreSQL y Docker: Guía completa para desarrollo backend

Meta Description: Aprende paso a paso a construir una API REST profesional con FastAPI, autenticación OAuth2, migraciones Alembic, tareas asíncronas con Celery, pruebas con pytest y despliegue con Docker y Kubernetes.

URL Slug: tutorial-fastapi-postgresql-docker-api-rest


1. Requisitos previos e instalación

1.1 Resumen

Antes de comenzar, asegúrate de tener instalado Python 3.11+, Docker y Docker Compose, Git, PostgreSQL, Redis y las herramientas necesarias para el manejo de migraciones y pruebas.

1.2 Pasos concretos

  • Verifica Python:

  • Verifica pip o Poetry:

  • Verifica Docker y Docker Compose:

  • Verifica Git:

  • Instalación local recomendada para PostgreSQL y Redis (alternativamente usar Docker, que cubriremos más adelante).

  • Instalación de herramientas de migración y pruebas:

1.3 Manejo de entorno y secretos

  • Crea un archivo .env.example con variables de entorno básicas:

  • Para desarrollo usa python-dotenv o direnv para cargar .env local.

  • En producción, gestiona secretos con servicios especializados (AWS Secrets Manager, Vault) y no almacenes claves sensibles en el código.

Checklist 1

  • [ ] Python 3.11+ instalado
  • [ ] Docker y Docker Compose instalados
  • [ ] PostgreSQL y Redis configurados (local o Docker)
  • [ ] Alembic y pytest instalados
  • [ ] Archivo .env.example preparado

2. Inicializar proyecto y estructura de carpetas

2.1 Resumen

Organiza tu proyecto con una estructura modular, idealmente usando Poetry para manejo de dependencias y virtualenvs. Incluye separaciones para API, modelos, configuración, pruebas y scripts.

2.2 Pasos concretos

  • Crear proyecto y activar entorno:

  • Crear estructura estándar:

  • Ejemplo básico de pyproject.toml generado por Poetry (extracto):

python
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
from datetime import datetime

class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primarykey=True)
username: str = Field(index=True, unique=True)
hashed
password: str
isactive: bool = Field(default=True)
orders: List[“Order”] = Relationship(back
populates=”user”)

class Product(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
description: Optional[str]
price: float

class Order(SQLModel, table=True):
id: Optional[int] = Field(default=None, primarykey=True)
user
id: int = Field(foreignkey=”user.id”)
created
at: datetime = Field(defaultfactory=datetime.utcnow)
user: Optional[User] = Relationship(back
populates=”orders”)
items: List[“OrderItem”] = Relationship(back_populates=”order”)

class OrderItem(SQLModel, table=True):
id: Optional[int] = Field(default=None, primarykey=True)
order
id: int = Field(foreignkey=”order.id”)
product
id: int = Field(foreignkey=”product.id”)
quantity: int
order: Optional[Order] = Relationship(back
populates=”items”)

python
from logging.config import fileConfig
from sqlalchemy import enginefromconfig
from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel
import asyncio
from src.app.models.models import User, Product, Order, OrderItem

config = context.config
fileConfig(config.configfilename)

target_metadata = SQLModel.metadata

def runmigrationsonline():
connectable = enginefromconfig(
config.getsection(config.configini_section),
prefix=’sqlalchemy.’,
poolclass=pool.NullPool,
future=True,
)

if context.isofflinemode():
# runmigrationsoffline() if needed
pass
else:
runmigrationsonline()

bash
alembic revision –autogenerate -m “Initial migration”
alembic upgrade head

python
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import AsyncSession, createasyncengine
from sqlalchemy.orm import sessionmaker
from src.app.core.config import settings

engine = createasyncengine(
settings.databaseurl,
echo=True,
pool
pre_ping=True,
)

asyncsession = sessionmaker(
engine, class
=AsyncSession, expireoncommit=False
)

async def initdb():
async with engine.begin() as conn:
await conn.run
sync(SQLModel.metadata.create_all)

python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
import logging
import uvicorn
from src.app.api.v1 import routesauth, routesproduct
from src.app.core.config import settings

app = FastAPI(
title=”FastAPI API REST”,
description=”API REST con FastAPI, PostgreSQL, Celery y más”,
version=”1.0.0″,
openapi_url=”/api/v1/openapi.json”
)

CORS (configurar dominios seguros)

app.addmiddleware(
CORSMiddleware,
allow
origins=settings.allowedhosts,
allow
methods=[““],
allow_headers=[“
“],
)

Compresión GZip

app.addmiddleware(GZipMiddleware, minimumsize=1000)

Middleware logging simple

@app.middleware(“http”)
async def logrequests(request: Request, callnext):
logger = logging.getLogger(“uvicorn.access”)
logger.info(f”Inicio request: {request.method} {request.url}”)
response = await callnext(request)
logger.info(f”Fin request: status
code={response.status_code}”)
return response

Routers

app.includerouter(routesauth.router, prefix=”/api/v1/auth”, tags=[“auth”])
app.includerouter(routesproduct.router, prefix=”/api/v1/products”, tags=[“products”])

if name == “main“:
uvicorn.run(“src.app.main:app”, host=”0.0.0.0″, port=8000, reload=True)

python
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List
from sqlmodel import select
from src.app.models.models import Product
from src.app.db.session import async_session
from src.app.api.v1.schemas import ProductCreate, ProductRead

router = APIRouter()

@router.get(“/”, responsemodel=List[ProductRead])
async def list
products(
limit: int = Query(10, le=100),
offset: int = Query(0),
):
async with async_session() as session:
query = select(Product).limit(limit).offset(offset)
result = await session.execute(query)
products = result.scalars().all()
return products

@router.post(“/”, responsemodel=ProductRead)
async def create
product(productin: ProductCreate):
product = Product.from
orm(productin)
async with async
session() as session:
session.add(product)
await session.commit()
await session.refresh(product)
return product

python
from passlib.context import CryptContext
from datetime import datetime, timedelta
from jose import jwt, JWTError

pwd_context = CryptContext(schemes=[“bcrypt”], deprecated=”auto”)

SECRETKEY = “SUPERSECRETKEYCHANGE”
ALGORITHM = “HS256”
ACCESS
TOKENEXPIREMINUTES = 30

def verifypassword(plainpassword, hashedpassword):
return pwd
context.verify(plainpassword, hashedpassword)

def getpasswordhash(password):
return pwd_context.hash(password)

def createaccesstoken(data: dict, expiresdelta: timedelta | None = None):
to
encode = data.copy()
expire = datetime.utcnow() + (expiresdelta or timedelta(minutes=ACCESSTOKENEXPIREMINUTES))
toencode.update({“exp”: expire})
encoded
jwt = jwt.encode(toencode, SECRETKEY, algorithm=ALGORITHM)
return encoded_jwt

def decodetoken(token: str):
try:
payload = jwt.decode(token, SECRET
KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None

python
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from datetime import timedelta
from src.app.models.models import User
from src.app.db.session import asyncsession
from src.app.core.security import get
passwordhash, verifypassword, createaccesstoken
from sqlmodel import select

router = APIRouter()

class UserRegister(BaseModel):
username: str
password: str

class Token(BaseModel):
accesstoken: str
token
type: str = “bearer”

@router.post(“/register”, responsemodel=Token)
async def register(user
in: UserRegister):
async with asyncsession() as session:
query = select(User).where(User.username == user
in.username)
result = await session.execute(query)
user = result.scalaroneornone()
if user:
raise HTTPException(status
code=400, detail=”Usuario ya existe”)
hashedpassword = getpasswordhash(userin.password)
user = User(username=userin.username, hashedpassword=hashedpassword)
session.add(user)
await session.commit()
access
token = createaccesstoken({“sub”: user.username})
return {“accesstoken”: accesstoken}

@router.post(“/login”, responsemodel=Token)
async def login(form
data: UserRegister):
async with asyncsession() as session:
query = select(User).where(User.username == form
data.username)
result = await session.execute(query)
user = result.scalaroneornone()
if not user or not verify
password(formdata.password, user.hashedpassword):
raise HTTPException(statuscode=401, detail=”Credenciales inválidas”)
access
token = createaccesstoken({“sub”: user.username})
return {“accesstoken”: accesstoken}

python
from celery import Celery
from src.app.core.config import settings

celeryapp = Celery(
“worker”,
broker=settings.redis
url,
backend=settings.redis_url
)

celeryapp.conf.taskroutes = {“src.app.tasks.tasks.*”: “main-queue”}

python
from src.app.tasks.celeryapp import celeryapp

@celeryapp.task
def send
email_task(email: str, subject: str, body: str):
print(f”Enviando email a {email} con asunto {subject}”)
# Aquí implementaríamos el envío real

python
from fastapi import APIRouter
from src.app.tasks.tasks import sendemailtask

router = APIRouter()

@router.post(“/send-email”)
async def sendemail(email: str):
send
email_task.delay(email, “Bienvenido”, “Gracias por registrarte”)
return {“message”: “Email encolado”}

bash
celery -A src.app.tasks.celeryapp.celeryapp worker –loglevel=info
celery -A src.app.tasks.celeryapp.celeryapp beat –loglevel=info

Cliente webhook –> API (verifica firma, valida idempotencia)–> DB
|
Respuesta

python
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

@app.exceptionhandler(StarletteHTTPException)
async def http
exceptionhandler(request: Request, exc: StarletteHTTPException):
return JSONResponse({“detail”: exc.detail, “status
code”: exc.statuscode}, statuscode=exc.status_code)

@app.exceptionhandler(RequestValidationError)
async def validation
exceptionhandler(request: Request, exc: RequestValidationError):
return JSONResponse({“detail”: exc.errors(), “status
code”: 422}, status_code=422)

json
{
“type”: “https://example.com/probs/out-of-credit”,
“title”: “You do not have enough credit.”,
“status”: 403,
“detail”: “Your current balance is 30, but that costs 50.”,
“instance”: “/account/12345/msgs/abc”
}

python
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi import Depends
security = HTTPBasic()

@app.get(“/docs”)
async def getswagger(credentials: HTTPBasicCredentials = Depends(security)):
correct
username = secrets.comparedigest(credentials.username, “admin”)
correct
password = secrets.comparedigest(credentials.password, “secret”)
if not (correct
username and correctpassword):
raise HTTPException(status
code=401, detail=”No autorizado”)
return getswaggeruihtml(openapiurl=”/api/v1/openapi.json”, title=”Docs”)

bash
pip install prometheus-fastapi-instrumentator

python
from prometheusfastapiinstrumentator import Instrumentator

@app.on_event(“startup”)
async def startup():
Instrumentator().instrument(app).expose(app)

python
@app.get(“/healthz”)
async def healthcheck():
return {“status”: “ok”}

python
import pytest
from httpx import AsyncClient
from src.app.main import app

@pytest.mark.asyncio
async def testregisterandlogin():
async with AsyncClient(app=app, base
url=”http://test”) as ac:
response = await ac.post(“/api/v1/auth/register”, json={“username”:”testuser”,”password”:”testpass”})
assert response.statuscode == 200
data = response.json()
assert “access
token” in data

python

Con testcontainers

from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer

@pytest.fixture(scope=”session”)
def postgrescontainer():
with PostgresContainer(“postgres:15”) as postgres:
yield postgres.get
connection_url()

@pytest.fixture(scope=”session”)
def rediscontainer():
with RedisContainer() as redis:
yield redis.get
connection_url()

dockerfile

Builder

FROM python:3.11-slim AS builder
WORKDIR /app
RUN pip install –upgrade pip
COPY pyproject.toml poetry.lock* /app/
RUN pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install –no-dev –no-interaction –no-ansi
COPY . /app

Runtime

FROM python:3.11-slim
WORKDIR /app
COPY –from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY –from=builder /app /app
ENV PYTHONUNBUFFERED=1
CMD [“uvicorn”, “src.app.main:app”, “–host”, “0.0.0.0”, “–port”, “8000”]

pycache
*.pyc
.env
.git

yaml
version: “3.9”
services:
api:
build: .
ports:
– 8000:8000
environment:
– DATABASEURL=postgresql+asyncpg://postgres:postgres@db:5432/fastapi
– REDIS
URL=redis://redis:6379/0
– SECRETKEY=changeme
depends
on:
– db
– redis

db:
image: postgres:15
restart: always
environment:
POSTGRESUSER: postgres
POSTGRES
PASSWORD: postgres
POSTGRES_DB: fastapi
volumes:
– pgdata:/var/lib/postgresql/data

redis:
image: redis:7-alpine
restart: always

volumes:
pgdata:

bash
docker compose up –build

bash
docker compose run api alembic upgrade head

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastapi-api
spec:
replicas: 2
selector:
matchLabels:
app: fastapi-api
template:
metadata:
labels:
app: fastapi-api
spec:
containers:
– name: api
image: yourrepo/fastapi_api:latest
ports:
– containerPort: 8000
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: “250m”
memory: “256Mi”
limits:
cpu: “500m”
memory: “512Mi”
envFrom:
– secretRef:
name: fastapi-secrets

yaml
apiVersion: v1
kind: Service
metadata:
name: fastapi-api-service
spec:
selector:
app: fastapi-api
ports:

  • protocol: TCP
    port: 80
    targetPort: 8000
    type: ClusterIP

yaml
name: CI
on: [push, pullrequest]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES
DB: fastapi
POSTGRESUSER: postgres
POSTGRES
PASSWORD: postgres
ports:
– 5432:5432
redis:
image: redis:7-alpine
ports:
– 6379:6379
steps:
– uses: actions/checkout@v3
– name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.11
– name: Install dependencies
run: |
python -m pip install –upgrade pip
pip install poetry
poetry install
– name: Run migrations
run: alembic upgrade head
– name: Run tests
run: pytest -v
– name: Lint
run: |
pip install ruff
ruff check src tests

toml
[tool.poetry]
name = “fastapi_project”
version = “0.1.0”

[tool.poetry.dependencies]
python = “^3.11”
fastapi = “^0.95”
uvicorn = {extras = [“standard”], version = “^0.21.1”}
sqlmodel = “^0.0.8”
asyncpg = “^0.27”
alembic = “^1.11”
passlib = {extras = [“bcrypt”], version = “^1.7”}
python-jose = “^3.3”
celery = “^5.2”
redis = “^4.5”
pytest = “^7.3”
pytest-asyncio = “^0.20”

[tool.poetry.scripts]
start = “src.app.main:main”

python
from pydantic import BaseSettings

class Settings(BaseSettings):
databaseurl: str
redis
url: str
secretkey: str
access
tokenexpireminutes: int = 30
allowed_hosts: list[str] = [“*”]

settings = Settings()

python
from pydantic import BaseModel

class ProductCreate(BaseModel):
name: str
description: str | None = None
price: float

class ProductRead(BaseModel):
id: int
name: str
description: str | None = None
price: float

env
DATABASEURL=postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi
REDIS
URL=redis://localhost:6379/0
SECRETKEY=yoursecretkey
ACCESS
TOKENEXPIREMINUTES=30
ALLOWED_HOSTS=*

bash

Instalar dependencias y activar entorno

poetry install
poetry shell

Ejecutar migraciones

alembic upgrade head

Levantar entorno local con Docker Compose

docker compose up –build

Ejecutar tests

pytest -q
`


FAQ

¿Qué versión de Python necesito? 3.11 o superior para aprovechar async y mejoras.

¿Por qué usar SQLModel y no solo SQLAlchemy? SQLModel integra validación Pydantic y ORM, facilitando validación y modelos.

¿Puedo usar otro broker diferente a Redis para Celery? Sí, RabbitMQ es otra opción popular.

¿Cómo protejo mis tokens JWT? Usa un secret fuerte y guarda refresh tokens de forma segura.

¿Por qué usar Testcontainers? Para pruebas fiables levantando instancias reales de DB y servicios.

¿Cómo escalo mi API en producción? Usa múltiples workers Uvicorn, balanceo de carga y Kubernetes/HPA.

¿Qué hacer si Celery tasks no se ejecutan? Verificar conexión a Redis, logs de worker y uso correcto de .delay().

¿Cómo debuggeo errores con Alembic? Revisa la configuración de URL, revisa env.py y el script de migración.


CHECKLIST GLOBAL PARA PRODUCCIÓN

  • [ ] Entorno configurado con variables y secretos seguros
  • [ ] Base de datos y migraciones desplegadas y actualizadas
  • [ ] API con endpoints organizados y testing cubierto
  • [ ] Seguridad implementada: autenticación, autorización, HTTPS
  • [ ] Tareas asíncronas con Celery funcionando
  • [ ] Observabilidad activa con logging, métricas y health checks
  • [ ] Contenedores construidos y orquestados localmente
  • [ ] Pipeline CI/CD configurado y probado
  • [ ] Manifiestos Kubernetes o equivalente preparados
  • [ ] Tests automatizados y cobertura aceptable
  • [ ] Políticas de seguridad y escalado listas

Esta guía le da a un desarrollador principiante-intermedio una ruta detallada, práctica y profesional para crear APIs REST robustas con FastAPI y entorno moderno, integrando pruebas, despliegue y buenas prácticas.


¡Comienza hoy mismo y construye APIs escalables y seguras con FastAPI!