Manual Fullstack CI/CD: FastAPI + Next.js + PostgreSQL

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.

FastAPI Next.js PostgreSQL Docker

Introducción

Este manual detalla cómo crear una aplicación fullstack moderna con:

Implementaremos un pipeline de CI/CD completo que:

  1. Construye y prueba la aplicación en GitHub Actions
  2. Crea imágenes Docker y las almacena en GitHub Packages
  3. Despliega automáticamente en un VPS usando Docker Compose
[Diagrama de arquitectura: GitHub → GitHub Actions → GitHub Packages → VPS con Docker]

1. Estructura del Proyecto

Nuestro proyecto seguirá una estructura monorepo con backend y frontend:

project/ ├── backend/ │ ├── app/ │ │ ├── __init__.py │ │ ├── main.py │ │ ├── database.py │ │ ├── models.py │ │ └── schemas.py │ ├── requirements.txt │ ├── Dockerfile │ └── .env.example ├── frontend/ │ ├── pages/ │ ├── public/ │ ├── styles/ │ ├── next.config.js │ ├── package.json │ ├── Dockerfile │ └── .env.example ├── docker-compose.yml └── .github/ └── workflows/ ├── ci.yml └── cd.yml

2. Configuración del Backend (FastAPI + PostgreSQL)

2.1 Crear la aplicación FastAPI

En el directorio backend, crea los siguientes archivos:

backend/app/main.py

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!"}

backend/requirements.txt

fastapi==0.95.2
uvicorn==0.22.0
sqlalchemy==2.0.15
psycopg2-binary==2.9.6
python-dotenv==1.0.0

backend/.env.example

# 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

2.2 Dockerfile para el backend

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}"]

3. Configuración del Frontend (Next.js)

3.1 Crear aplicación Next.js

En el directorio frontend, inicializa una aplicación Next.js:

npx create-next-app@latest .

Instala axios para llamadas a la API:

npm install axios

frontend/pages/index.js

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>
  )
}

frontend/.env.example

# URL del backend API
NEXT_PUBLIC_API_URL=http://localhost:8000

frontend/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  // Configuración para Docker
  output: 'standalone',
}

module.exports = nextConfig

3.2 Dockerfile para el frontend

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"]

4. Configuración de Docker Compose

4.1 Archivo docker-compose.yml

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:

.env.example (en la raíz del proyecto)

# 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

5. Configuración de GitHub Actions para CI/CD

5.1 Workflow de CI (Integración Continua)

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 }}

5.2 Workflow de CD (Despliegue Continuo)

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
Configuración requerida: Debes configurar los siguientes secrets en GitHub:
  • VPS_HOST - IP o dominio de tu VPS
  • VPS_USER - Usuario SSH (ej. root)
  • VPS_SSH_KEY - Clave privada SSH
  • DB_PASSWORD - Contraseña segura para PostgreSQL
  • GHCR_TOKEN - Token de GitHub con permisos para leer packages

6. Configuración del VPS

6.1 Preparar el servidor

sudo apt update && sudo apt upgrade -y
sudo apt install -y docker.io docker-compose
sudo systemctl enable docker
sudo systemctl start docker

Crea el directorio para la aplicación:

sudo mkdir -p /opt/fullstack-app
sudo chown -R $USER:$USER /opt/fullstack-app

6.2 Configurar firewall

Abre los puertos necesarios:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3000/tcp
sudo ufw allow 8000/tcp
sudo ufw enable

7. Despliegue y Pruebas

7.1 Primer despliegue manual

Para el primer despliegue, puedes hacerlo manualmente:

cd /opt/fullstack-app
# Copia docker-compose.yml y .env
docker-compose up -d

Verifica que los contenedores estén corriendo:

docker-compose ps

7.2 Probar la aplicación

Verifica que ambos servicios estén funcionando:

Backend (FastAPI):

curl http://localhost:8000

Deberías ver: {"message":"Welcome to FastAPI backend!"}

Frontend (Next.js):

Abre en tu navegador: http://tu-vps-ip:3000

8. Configuración Avanzada

8.1 Configurar Nginx como proxy inverso

Para producción, es recomendable usar Nginx:

sudo apt install -y 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:

sudo ln -s /etc/nginx/sites-available/fullstack-app /etc/nginx/sites-enabled
sudo nginx -t
sudo systemctl restart nginx

8.2 Configurar HTTPS con Let's Encrypt

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d tu-dominio.com
sudo certbot renew --dry-run

Conclusión

Has configurado exitosamente un pipeline completo de CI/CD para una aplicación fullstack con:

¡Felicidades! Ahora cada vez que hagas push a la rama main, tu aplicación se construirá, probará y desplegará automáticamente en producción.

Próximos pasos: