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: