Guía completa para implementar un pipeline de CI/CD para una aplicación fullstack con backend FastAPI, frontend Next.js y PostgreSQL, usando Docker, GitHub Actions y despliegue en VPS.
Este manual detalla cómo crear una aplicación fullstack moderna con:
Implementaremos un pipeline de CI/CD completo que:
Nuestro proyecto seguirá una estructura monorepo con backend y frontend:
En el directorio backend
, crea los siguientes archivos:
from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from pydantic import BaseModel import os from dotenv import load_dotenv load_dotenv() # Configuración CORS (permite conexiones desde el frontend) origins = [ "http://localhost:3000", os.getenv("FRONTEND_URL", "http://localhost:3000") ] # Configuración de la base de datos DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db:5432/fastapi_db") engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() # Modelo SQLAlchemy class Item(Base): __tablename__ = "items" id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True) description = Column(String, index=True) # Esquema Pydantic class ItemCreate(BaseModel): name: str description: str class ItemResponse(ItemCreate): id: int class Config: orm_mode = True # Crear tablas Base.metadata.create_all(bind=engine) app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.post("/items/", response_model=ItemResponse) def create_item(item: ItemCreate): db = SessionLocal() db_item = Item(**item.dict()) db.add(db_item) db.commit() db.refresh(db_item) db.close() return db_item @app.get("/items/", response_model=list[ItemResponse]) def read_items(skip: int = 0, limit: int = 10): db = SessionLocal() items = db.query(Item).offset(skip).limit(limit).all() db.close() return items @app.get("/items/{item_id}", response_model=ItemResponse) def read_item(item_id: int): db = SessionLocal() item = db.query(Item).filter(Item.id == item_id).first() db.close() if item is None: raise HTTPException(status_code=404, detail="Item not found") return item @app.get("/") def read_root(): return {"message": "Welcome to FastAPI backend!"}
fastapi==0.95.2 uvicorn==0.22.0 sqlalchemy==2.0.15 psycopg2-binary==2.9.6 python-dotenv==1.0.0
# Configuración de FastAPI APP_HOST=0.0.0.0 APP_PORT=8000 # Configuración de la base de datos DATABASE_URL=postgresql://user:password@db:5432/fastapi_db # Configuración CORS FRONTEND_URL=http://localhost:3000
Crea backend/Dockerfile
:
# Etapa de construcción FROM python:3.9-slim as builder WORKDIR /app # Instalar dependencias del sistema RUN apt-get update && apt-get install -y \ gcc \ python3-dev \ && rm -rf /var/lib/apt/lists/* # Copiar requirements e instalar dependencias COPY requirements.txt . RUN pip install --user -r requirements.txt # Etapa final FROM python:3.9-slim WORKDIR /app # Copiar dependencias instaladas desde la etapa de construcción COPY --from=builder /root/.local /root/.local COPY . . # Asegurarse de que los scripts en .local sean ejecutables ENV PATH=/root/.local/bin:$PATH # Variables de entorno por defecto ENV APP_HOST=0.0.0.0 ENV APP_PORT=8000 # Exponer el puerto EXPOSE ${APP_PORT} # Comando para ejecutar la aplicación CMD ["uvicorn", "app.main:app", "--host", "${APP_HOST}", "--port", "${APP_PORT}"]
En el directorio frontend
, inicializa una aplicación Next.js:
Instala axios para llamadas a la API:
import { useState, useEffect } from 'react' import axios from 'axios' import styles from '../styles/Home.module.css' export default function Home() { const [items, setItems] = useState([]) const [name, setName] = useState('') const [description, setDescription] = useState('') const [loading, setLoading] = useState(false) const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' useEffect(() => { fetchItems() }, []) const fetchItems = async () => { try { const res = await axios.get(`${API_URL}/items`) setItems(res.data) } catch (err) { console.error('Error fetching items:', err) } } const addItem = async (e) => { e.preventDefault() setLoading(true) try { await axios.post(`${API_URL}/items`, { name, description }) setName('') setDescription('') await fetchItems() } catch (err) { console.error('Error adding item:', err) } finally { setLoading(false) } } return ( <div className={styles.container}> <main className={styles.main}> <h1 className={styles.title}> Fullstack App with <a href="https://nextjs.org">Next.js</a> and <a href="https://fastapi.tiangolo.com">FastAPI</a> </h1> <div className={styles.grid}> <div className={styles.card}> <h2>Add Item</h2> <form onSubmit={addItem}> <div> <label>Name:</label> <input type="text" value={name} onChange={(e) => setName(e.target.value)} required /> </div> <div> <label>Description:</label> <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} required /> </div> <button type="submit" disabled={loading}> {loading ? 'Adding...' : 'Add Item'} </button> </form> </div> <div className={styles.card}> <h2>Items List</h2> {items.length === 0 ? ( <p>No items yet. Add one!</p> ) : ( <ul> {items.map((item) => ( <li key={item.id}> <strong>{item.name}</strong> - {item.description} </li> ))} </ul> )} </div> </div> </main> </div> ) }
# URL del backend API NEXT_PUBLIC_API_URL=http://localhost:8000
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, // Configuración para Docker output: 'standalone', } module.exports = nextConfig
Crea frontend/Dockerfile
:
# Etapa de construcción FROM node:18-alpine AS builder WORKDIR /app # Copiar archivos de dependencias COPY package.json package-lock.json ./ # Instalar dependencias RUN npm ci # Copiar el resto de los archivos COPY . . # Construir la aplicación RUN npm run build # Etapa de producción FROM node:18-alpine WORKDIR /app # Copiar desde la etapa de construcción COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/public ./public # Variables de entorno ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Exponer el puerto EXPOSE 3000 # Comando para ejecutar la aplicación CMD ["npm", "start"]
En la raíz del proyecto, crea docker-compose.yml
:
version: '3.8' services: backend: build: context: ./backend dockerfile: Dockerfile ports: - "${BACKEND_PORT}:${BACKEND_PORT}" environment: - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - APP_HOST=${BACKEND_HOST} - APP_PORT=${BACKEND_PORT} - FRONTEND_URL=${FRONTEND_URL} depends_on: - db restart: unless-stopped frontend: build: context: ./frontend dockerfile: Dockerfile ports: - "${FRONTEND_PORT}:3000" environment: - NEXT_PUBLIC_API_URL=${BACKEND_URL} depends_on: - backend restart: unless-stopped db: image: postgres:13-alpine environment: - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=${DB_NAME} volumes: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped volumes: postgres_data:
# Configuración de la base de datos DB_USER=user DB_PASSWORD=password DB_NAME=fastapi_db # Configuración del backend BACKEND_HOST=0.0.0.0 BACKEND_PORT=8000 BACKEND_URL=http://localhost:8000 # Configuración del frontend FRONTEND_PORT=3000 FRONTEND_URL=http://localhost:3000
Crea .github/workflows/ci.yml
:
name: CI Pipeline on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test-backend: runs-on: ubuntu-latest services: postgres: image: postgres:13-alpine env: POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password POSTGRES_DB: test_db ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install backend dependencies working-directory: ./backend run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest - name: Run backend tests working-directory: ./backend env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db run: | pytest -v test-frontend: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install frontend dependencies working-directory: ./frontend run: npm ci - name: Run frontend tests working-directory: ./frontend run: npm run test build-and-push: needs: [test-backend, test-frontend] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Log in to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for backend id: meta-backend uses: docker/metadata-action@v4 with: images: ghcr.io/${{ github.repository }}/backend tags: | type=sha type=ref,event=branch - name: Build and push backend image uses: docker/build-push-action@v4 with: context: ./backend push: true tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} - name: Extract metadata for frontend id: meta-frontend uses: docker/metadata-action@v4 with: images: ghcr.io/${{ github.repository }}/frontend tags: | type=sha type=ref,event=branch - name: Build and push frontend image uses: docker/build-push-action@v4 with: context: ./frontend push: true tags: ${{ steps.meta-frontend.outputs.tags }} labels: ${{ steps.meta-frontend.outputs.labels }}
Crea .github/workflows/cd.yml
:
name: CD Pipeline on: workflow_run: workflows: ["CI Pipeline"] branches: [main] types: - completed jobs: deploy: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Copy files to VPS uses: appleboy/scp-action@v0.1.4 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} source: "docker-compose.yml,.env.example" target: "/opt/fullstack-app" - name: SSH to VPS and deploy uses: appleboy/ssh-action@v0.1.10 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd /opt/fullstack-app cp .env.example .env # Edita el .env con tus valores de producción echo "DB_USER=prod_user" >> .env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env echo "BACKEND_URL=http://${{ secrets.VPS_HOST }}:8000" >> .env echo "FRONTEND_URL=http://${{ secrets.VPS_HOST }}" >> .env # Login to GitHub Container Registry echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin # Deploy docker-compose down docker image prune -af docker-compose pull docker-compose up -d
VPS_HOST
- IP o dominio de tu VPSVPS_USER
- Usuario SSH (ej. root)VPS_SSH_KEY
- Clave privada SSHDB_PASSWORD
- Contraseña segura para PostgreSQLGHCR_TOKEN
- Token de GitHub con permisos para leer packagesCrea el directorio para la aplicación:
Abre los puertos necesarios:
Para el primer despliegue, puedes hacerlo manualmente:
Verifica que los contenedores estén corriendo:
Verifica que ambos servicios estén funcionando:
Backend (FastAPI):
Deberías ver: {"message":"Welcome to FastAPI backend!"}
Frontend (Next.js):
Abre en tu navegador: http://tu-vps-ip:3000
Para producción, es recomendable usar Nginx:
Crea un archivo de configuración en /etc/nginx/sites-available/fullstack-app
:
server { listen 80; server_name tu-dominio.com; location / { proxy_pass http://localhost:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/ { proxy_pass http://localhost:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
Habilita el sitio y reinicia Nginx:
Has configurado exitosamente un pipeline completo de CI/CD para una aplicación fullstack con: