Back to 0fee
0fee

Despliegue en produccion con Docker para EasyPanel

Como containerizamos 0fee.dev con Docker para despliegue en EasyPanel con 3 servicios, nginx y cabeceras de seguridad. Por Juste A. Gnimavo.

Thales & Claude | March 30, 2026 9 min 0fee
EN/ FR/ ES
dockerdeploymenteasypanelnginxproduction

Construir una plataforma de pagos es una cosa. Llevarla a produccion es otra. En la sesion 086, containerizamos 0fee.dev para despliegue en EasyPanel -- un PaaS autoalojado que funciona sobre Docker. Tres Dockerfiles (backend, frontend, worker), un archivo de requisitos solo para produccion, una configuracion de nginx con cabeceras de seguridad y compresion gzip, y enrutamiento por subdominio que mapea api.0fee.dev al backend y 0fee.dev al frontend.

Esta sesion tambien produjo las versiones v1.53.0 a v1.55.0, siendo una de las sesiones finales antes de que la plataforma entrara en produccion.

La arquitectura de tres servicios

0fee.dev en produccion funciona como tres servicios Docker:

+-------------------+     +-------------------+     +-------------------+
|    Frontend       |     |     Backend       |     |     Worker        |
|   (SolidJS +      |     |   (FastAPI +      |     |   (Celery +      |
|    nginx)         |     |    Uvicorn)       |     |    Redis)        |
|                   |     |                   |     |                   |
|  0fee.dev         |     |  api.0fee.dev     |     |  (interno)       |
|  Puerto 80/443    |     |  Puerto 8000      |     |  Sin puerto pub. |
+-------------------+     +-------------------+     +-------------------+
         |                         |                         |
         +------------ Host Docker EasyPanel ----------------+

El frontend sirve la aplicacion SolidJS a traves de nginx. El backend ejecuta FastAPI con Uvicorn. El worker ejecuta Celery para tareas en segundo plano (entrega de webhooks, generacion de facturas, envio de correos). Solo el frontend y el backend son accesibles publicamente.

Dockerfile del backend

dockerfile# backend/Dockerfile
FROM python:3.12-slim AS base

WORKDIR /app

# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Instalar dependencias Python (solo produccion)
COPY requirements-prod.txt .
RUN pip install --no-cache-dir -r requirements-prod.txt

# Copiar codigo de la aplicacion
COPY . .

# Crear usuario no root
RUN useradd --create-home appuser
USER appuser

# Verificacion de salud
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Decisiones clave:

python:3.12-slim en lugar de python:3.12-alpine. Alpine usa musl libc en lugar de glibc, lo que causa problemas de compatibilidad con algunos paquetes Python -- particularmente psycopg2 y cryptography. La imagen slim anade ~50MB pero elimina horas de depuracion.

requirements-prod.txt sin herramientas de desarrollo. El archivo de requisitos de produccion excluye pytest, black, ruff, mypy y otras dependencias de desarrollo. Esto reduce el tamano de la imagen en ~200MB y la superficie de ataque.

Usuario no root. El appuser ejecuta la aplicacion. Incluso si el contenedor se ve comprometido, el atacante no tiene privilegios de root.

4 workers de Uvicorn. Cada worker maneja solicitudes independientemente. En una instancia de EasyPanel de 2 nucleos, 4 workers (2x nucleos de CPU) proporcionan buen rendimiento sin sobresubscripcion.

El archivo de requisitos de produccion

text# requirements-prod.txt
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
httpx==0.26.0
celery[redis]==5.3.6
python-multipart==0.0.6
pydantic==2.5.3
pydantic-settings==2.1.0
sqladmin==0.16.1
weasyprint==60.2
Pillow==10.2.0
jinja2==3.1.3
python-dotenv==1.0.1
cryptography==42.0.2

Sin pytest, sin black, sin ruff, sin ipdb. Las imagenes de produccion deben contener solo lo que produccion necesita.

Dockerfile del frontend

El Dockerfile del frontend es una compilacion multi-etapa: SolidJS compila a archivos estaticos, luego nginx los sirve:

dockerfile# frontend/Dockerfile
FROM node:20-slim AS builder

WORKDIR /app

# Instalar dependencias
COPY package.json package-lock.json ./
RUN npm ci --production=false

# Argumento de compilacion para URL de API
ARG VITE_API_URL=https://api.0fee.dev
ENV VITE_API_URL=$VITE_API_URL

# Copiar fuente y compilar
COPY . .
RUN npm run build

# Etapa de produccion: nginx sirve archivos estaticos
FROM nginx:1.25-alpine AS production

# Copiar configuracion personalizada de nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copiar activos compilados de la etapa builder
COPY --from=builder /app/dist /usr/share/nginx/html

# Verificacion de salud
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD wget -qO- http://localhost/ || exit 1

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

El detalle critico es VITE_API_URL. Vite incrusta las variables de entorno en tiempo de compilacion, no en tiempo de ejecucion. Esto significa que la URL de la API debe conocerse cuando se construye la imagen Docker:

bash# Compilar con URL de API de produccion
docker build \
    --build-arg VITE_API_URL=https://api.0fee.dev \
    -t 0fee-frontend:v1.55.0 \
    ./frontend

Si necesitas diferentes URLs de API para staging y produccion, compilas imagenes separadas. Esta es una restriccion de Vite, no de Docker.

Dockerfile del worker

dockerfile# worker/Dockerfile
FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements-prod.txt .
RUN pip install --no-cache-dir -r requirements-prod.txt

COPY . .

RUN useradd --create-home celeryuser
USER celeryuser

CMD ["celery", "-A", "tasks.celery_app", "worker", \
     "--loglevel=info", "--concurrency=2", \
     "--max-tasks-per-child=100"]

El flag --max-tasks-per-child=100 reinicia los procesos worker despues de 100 tareas. Esto previene que las fugas de memoria se acumulen en procesos worker de larga ejecucion -- un problema comun con WeasyPrint (generacion de PDF) que puede tener fugas de memoria en documentos complejos.

La configuracion de nginx

nginx# frontend/nginx.conf
server {
    listen 80;
    server_name 0fee.dev www.0fee.dev;
    root /usr/share/nginx/html;
    index index.html;

    # Compresion Gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        font/woff2;

    # Cabeceras de seguridad
    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 Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://appleid.cdn-apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.0fee.dev; frame-src 'self' https://appleid.apple.com;" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    # Cachear activos estaticos agresivamente
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Respaldo SPA: servir index.html para todas las rutas
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Denegar acceso a archivos ocultos
    location ~ /\. {
        deny all;
        return 404;
    }
}

Cabeceras de seguridad explicadas

CabeceraValorProposito
X-Frame-OptionsSAMEORIGINPreviene clickjacking bloqueando incrustacion en iframe de otros dominios
X-Content-Type-OptionsnosniffPreviene que los navegadores hagan sniffing de tipo MIME
X-XSS-Protection1; mode=blockHabilita filtro XSS del navegador (legado, pero inofensivo)
Referrer-Policystrict-origin-when-cross-originLimita informacion de referrer enviada a otros origenes
Content-Security-Policy(ver arriba)Restringe carga de recursos a origenes confiables
Permissions-Policycamera=(), microphone=(), geolocation=()Desactiva APIs del navegador que el sitio no necesita

La Content-Security-Policy es la cabecera mas cuidadosamente elaborada. Permite: - Scripts de si mismo y en linea (necesario para hidratacion de SolidJS) mas el SDK JS de Apple - Estilos de si mismo, en linea y Google Fonts - Fuentes de si mismo y Google Fonts statico - Imagenes de si mismo, URIs data y cualquier fuente HTTPS - Conexiones API solo a api.0fee.dev - Frames de si mismo y Apple (para popup de Apple Sign In)

Compresion Gzip

La configuracion gzip_comp_level 6 es un compromiso deliberado entre tasa de compresion y uso de CPU. El nivel 6 logra ~90% de la compresion maxima (nivel 9) al ~50% del costo de CPU. Para un bundle de SolidJS, esto tipicamente reduce el tamano de transferencia de ~300KB a ~90KB.

Configuracion del servicio EasyPanel

EasyPanel gestiona contenedores Docker a traves de una interfaz web. Los tres servicios de 0fee.dev se configuran como:

yaml# Configuracion conceptual de EasyPanel
services:
  - name: 0fee-frontend
    image: 0fee-frontend:v1.55.0
    domains:
      - host: 0fee.dev
        port: 80
      - host: www.0fee.dev
        port: 80
    replicas: 1
    resources:
      cpu: 0.5
      memory: 256M

  - name: 0fee-backend
    image: 0fee-backend:v1.55.0
    domains:
      - host: api.0fee.dev
        port: 8000
    replicas: 1
    resources:
      cpu: 1.0
      memory: 512M
    env:
      - DATABASE_URL=postgresql+asyncpg://...
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=${SECRET_KEY}
      - ENVIRONMENT=production

  - name: 0fee-worker
    image: 0fee-worker:v1.55.0
    domains: []  # Sin acceso publico
    replicas: 1
    resources:
      cpu: 0.5
      memory: 512M
    env:
      - DATABASE_URL=postgresql+asyncpg://...
      - REDIS_URL=redis://redis:6379/0

Enrutamiento por subdominio

EasyPanel maneja la terminacion SSL y enruta el trafico basado en subdominio:

  • 0fee.dev y www.0fee.dev enrutan al contenedor frontend en el puerto 80
  • api.0fee.dev enruta al contenedor backend en el puerto 8000

HTTPS es manejado por la integracion incorporada de Let's Encrypt de EasyPanel. Los contenedores Docker solo necesitan manejar HTTP.

Etiquetado de versiones: v1.53.0 a v1.55.0

La sesion de containerizacion produjo tres versiones:

VersionCambios
v1.53.0Dockerfiles iniciales, containerizacion basica
v1.54.0Cabeceras de seguridad nginx anadidas, gzip, CSP
v1.55.0Requisitos de produccion, verificaciones de salud, ajuste de worker

Cada version fue etiquetada en git y compilada como una imagen Docker separada. EasyPanel puede revertir a cualquier version anterior cambiando la etiqueta de la imagen -- una capacidad critica para una plataforma de pagos donde los malos despliegues necesitan reversion instantanea.

Lo que aprendimos

Las compilaciones multi-etapa son esenciales para frontends. La etapa builder de Node.js pesa ~1.2GB. La etapa final de nginx pesa ~40MB. Sin compilaciones multi-etapa, envias un gigabyte de herramientas de compilacion a produccion.

VITE_API_URL debe ser un argumento de compilacion. Esto nos sorprendio. Vite reemplaza import.meta.env.VITE_API_URL en tiempo de compilacion con una cadena literal. No hay forma de inyectarlo al arrancar el contenedor sin un entrypoint personalizado que reescriba el JavaScript compilado -- un enfoque fragil. La inyeccion en tiempo de compilacion es el patron previsto.

requirements-prod.txt no es opcional. Las dependencias de desarrollo anaden cientos de megabytes e introducen paquetes innecesarios en el entorno de produccion. Mantiene un archivo separado.

Content-Security-Policy es dificil de configurar correctamente. Nuestra primera CSP bloqueo Apple Sign In, Google Fonts y la conexion API. Tomo tres iteraciones obtener una politica lo suficientemente estricta para ser significativa pero lo suficientemente permisiva para que la aplicacion funcionara.

La gestion de memoria del worker requiere max-tasks-per-child. Sin el, los workers de Celery crecen ilimitadamente en memoria. Con WeasyPrint generando PDFs, un worker puede consumir mas de 500MB si no se recicla periodicamente.


Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles