DEV Community

Cover image for El MVP que funciona (1-100 usuarios)
Norman Torres
Norman Torres

Posted on

El MVP que funciona (1-100 usuarios)

Esto es fantasía.

El 1° de enero lanzamos una plataforma de gestión financiera personal. Conecta cuentas bancarias, categoriza gastos, establece presupuestos y genera insights. Ese mismo día tuvimos nuestro primer usuario. La meta es llegar a 100 este mes.

Este post es sobre la infraestructura detrás de ese MVP. No sobre código, arquitectura de software, ni patrones de diseño. Sobre servidores, contenedores, costos, y las decisiones pragmáticas que te permiten lanzar algo real con ~$25 USD al mes.

El Stack

Frontend Web:    React
Mobile:          React Native
Backend:         NestJS
Base de datos:   PostgreSQL
Contenedores:    Docker + Docker Compose
Servidor:        AWS EC2 + Nginx
Enter fullscreen mode Exit fullscreen mode

Nada exótico. Tecnologías probadas que cualquier developer puede mantener.

La arquitectura

                            ┌───────────────────────────────────────────────┐
                            │               EC2 t3.small                    │
                            │                                               │
┌──────────┐                │  ┌─────────────────────────────────────────┐  │
│ Usuario  │───HTTPS:443───▶│  │            Docker Network               │  │
│ (Web)    │                │  │                                         │  │
└──────────┘                │  │  ┌───────┐   ┌───────┐   ┌──────────┐   │  │
                            │  │  │ Nginx │──▶│  API  │──▶│ Postgres │   │  │
┌──────────┐                │  │  │  :80  │   │ :3000 │   │  :5432   │   │  │
│ Usuario  │───HTTPS:443───▶│  │  │ :443  │   └───────┘   └──────────┘   │  │
│ (Mobile) │                │  │  └───────┘                              │  │
└──────────┘                │  │                                         │  │
                            │  └─────────────────────────────────────────┘  │
                            └───────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Todo en una instancia EC2, pero cada servicio aislado en su contenedor. Un docker compose up -d y todo corre.

¿Por qué Docker para un MVP?

La respuesta corta: porque facilita la vida.

Beneficios clave

  • Consistencia entre ambientes: Lo que corre en mi laptop corre igual en producción. Adiós "en mi máquina funciona".
  • Despliegues rápidos y predecibles: Actualizar la API es un docker compose pull y docker compose up -d. Sin sorpresas.
  • Aislamiento de servicios: La base de datos no contamina el sistema host. Si algo falla, solo afecta su contenedor.
  • Escalabilidad futura: Cuando el MVP crezca, migrar a múltiples servidores o servicios gestionados será más sencillo.
  • Facilidad para nuevos desarrolladores: Un nuevo dev solo necesita Docker y el repo. Nada de instalar PostgreSQL localmente o configurar variables de entorno complicadas.

Docker Compose

# docker-compose.yml
services:
  api:
    image: turegistro/api:latest
    # build: ./api  # Para desarrollo local
    container_name: finanzas-api
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://usuario:password@db:5432/finanzas
      - PORT=3000
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal

  db:
    image: postgres:16-alpine
    container_name: finanzas-db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=usuario
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=finanzas
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./backup:/backup
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U usuario -d finanzas"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  nginx:
    image: nginx:alpine
    container_name: finanzas-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./frontend/dist:/var/www/app:ro
    depends_on:
      - api
    networks:
      - internal

volumes:
  postgres_data:

networks:
  internal:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Detalles importantes

  • restart: unless-stopped Si el contenedor crashea, Docker lo reinicia automáticamente. Si yo lo detengo manualmente, no lo reinicia.
  • depends_on con condition: service_healthy La API no inicia hasta que PostgreSQL esté listo para aceptar conexiones. Evita errores de conexión en el startup.
  • Volumen para PostgreSQL postgres_data persiste los datos fuera del contenedor. Si recreo el contenedor de Postgres, los datos sobreviven.
  • Red interna Los contenedores se comunican por nombre (db, api) dentro de la red internal. PostgreSQL nunca está expuesto a internet.

El servidor

Selección de instancia

Instancia:      t3.small
vCPUs:          2
RAM:            2 GB
Almacenamiento: 30 GB gp3
Región:         us-west-2 (Oregón)
SO:             Ubuntu 24.04 LTS
Enter fullscreen mode Exit fullscreen mode

¿Por qué t3.small y no t3.micro?

La micro tiene 1GB de RAM. Docker ya consume ~100MB, PostgreSQL quiere ~256MB para buffers, la API otros ~200MB, Nginx es ligero pero suma. Con 1GB estás en el límite desde el arranque.

Con 2GB hay espacio para crecer, caches, y evitar OOM kills.

¿Por qué instancia "burstable" (t3)?
Las t3 acumulan créditos de CPU cuando están idle y los gastan en picos. Un MVP tiene ráfagas de tráfico, no carga constante.

Nuestro uso promedio es ~8% de CPU. Los créditos se acumulan más rápido de lo que los gastamos.

Costos reales

Concepto Costo mensual
EC2 t3.small (on-demand) ~$15.00 USD
EBS 30GB gp3 ~$2.40 USD
Data transfer (estimado) ~$2-5 USD
Dominio (anualizado) ~$1.00 USD
Docker Hub (free tier) $0
Total ~$20-25 USD

Optimizaciones que NO hicimos

  • Reserved Instances: Compromiso de 1-3 años. El MVP puede pivotar.
  • ECR en lugar de Docker Hub: Más control, pero Docker Hub free tier es suficiente para imágenes privadas limitadas.
  • Spot Instances: AWS puede terminarlas. No para producción.

Networking y seguridad

Security Groups

Inbound:
  - 22 (SSH)      → Solo mi IP
  - 80 (HTTP)     → 0.0.0.0/0 (redirige a 443)
  - 443 (HTTPS)   → 0.0.0.0/0

Outbound:
  - All traffic   → 0.0.0.0/0
Enter fullscreen mode Exit fullscreen mode

PostgreSQL (5432) NO está expuesto. Solo existe dentro de la red de Docker. Para acceder remotamente:

# Túnel SSH + docker exec
ssh -i ~/.ssh/mi_llave.pem usuario@servidor
docker exec -it finanzas-db psql -U usuario -d finanzas
Enter fullscreen mode Exit fullscreen mode

Variables de entorno

Los secrets no van en el docker-compose.yml del repo. En el servidor:

# /opt/app/.env
DATABASE_URL=postgresql://usuario:password_real@db:5432/finanzas
JWT_SECRET=secret_real
Enter fullscreen mode Exit fullscreen mode

Y en el compose:

api:
  env_file:
    - .env
Enter fullscreen mode Exit fullscreen mode

El archivo .env está en .gitignore. Cada ambiente tiene el suyo.
Por ambiente me refiero a desarrollo local y producción.

Nginx con Docker

Configuración

# nginx/conf.d/default.conf
upstream api {
    server api:3000;
}

server {
    listen 80;
    server_name dominio.com api.dominio.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name dominio.com;

    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    root /var/www/app;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

server {
    listen 443 ssl http2;
    server_name api.dominio.com;

    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    location / {
        proxy_pass http://api;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
Enter fullscreen mode Exit fullscreen mode

Nota: server api:3000 funciona porque Docker resuelve api al contenedor de la API dentro de la red interna.

SSL con Let's Encrypt

Certbot corre en el host (no en contenedor) para simplicidad:

sudo apt install certbot
sudo certbot certonly --standalone -d tudominio.com -d api.tudominio.com

# Copiar certificados donde Nginx los espera
sudo cp /etc/letsencrypt/live/tudominio.com/fullchain.pem /opt/app/nginx/ssl/
sudo cp /etc/letsencrypt/live/tudominio.com/privkey.pem /opt/app/nginx/ssl/

# Reiniciar Nginx para que tome los nuevos certs
docker compose restart nginx
Enter fullscreen mode Exit fullscreen mode

Renovación automática via cron:

# /etc/cron.d/certbot-renew
0 3 * * * root certbot renew --quiet --post-hook "cp /etc/letsencrypt/live/tudominio.com/*.pem /opt/app/nginx/ssl/ && docker compose -f /opt/app/docker-compose.yml restart nginx"
Enter fullscreen mode Exit fullscreen mode

Deploy

Proceso actual

# 1. Build y push de la imagen (local)
docker build -t turegistro/api:latest ./api
docker push turegistro/api:latest

# 2. Build del frontend
cd frontend && npm run build

# 3. En el servidor
ssh usuario@servidor
cd /opt/app

# 4. Pull de la nueva imagen y restart
docker compose pull api
docker compose up -d api

# 5. Actualizar frontend (rsync desde local)
rsync -avz --delete frontend/dist/ usuario@servidor:/opt/app/frontend/dist/
Enter fullscreen mode Exit fullscreen mode

Rollback

Si algo sale mal:

# Ver imágenes disponibles
docker images turegistro/api

# Volver a versión anterior
docker compose down
docker tag turegistro/api:previous turegistro/api:latest
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Zero-downtime deploy (futuro)

Por ahora hay ~5 segundos de downtime en cada deploy.

Backups

Base de datos

Script de backup que corre dentro del contenedor:

#!/bin/bash
# /opt/app/scripts/backup.sh
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
docker exec finanzas-db pg_dump -U usuario -Fc finanzas > /opt/app/backup/db_${TIMESTAMP}.dump

# Mantener solo últimos 7 días
find /opt/app/backup -name "db_*.dump" -mtime +7 -delete
Enter fullscreen mode Exit fullscreen mode

Cron en el host:

# /etc/cron.d/db-backup
0 3 * * * root /opt/app/scripts/backup.sh
Enter fullscreen mode Exit fullscreen mode

Subir a S3

# Agregar al script de backup
aws s3 cp /opt/app/backup/db_${TIMESTAMP}.dump s3://tu-bucket/backups/
Enter fullscreen mode Exit fullscreen mode

Docker volumes

El volumen postgres_data vive en /var/lib/docker/volumes/. Si el servidor muere, los datos mueren con él. Por eso el backup a S3 es importante.

Monitoreo

Uptime

UptimeRobot (gratis):

  • Ping a https://api.dominio.com/health cada 5 min
  • Alerta por email/Telegram si no responde

Logs

Docker centraliza los logs:

# Logs de todos los servicios
docker compose logs -f

# Solo la API, últimas 100 líneas
docker compose logs -f --tail 100 api

# Logs de un período específico
docker compose logs --since 2024-01-15T10:00:00 api
Enter fullscreen mode Exit fullscreen mode

Lo que NO tenemos

Servicio Por qué no
Kubernetes Un servidor, tres contenedores. docker compose es suficiente.
CDN (CloudFront) Frontend de ~500KB, usuarios en México. Latencia imperceptible.
Load Balancer Un servidor. Nada que balancear.
Redis Sin cache. Queries directas a Postgres. Dataset pequeño.
RDS Cuesta lo mismo que toda la infra actual.
ECS/Fargate Overhead de configuración sin beneficio para esta escala.
Terraform Un servidor, un compose file. Lo documento en el README.
CI/CD 1-2 deploys por semana. El proceso manual toma 3 minutos.

¿Qué va a romper primero?

1. Disco lleno

Logs de Docker crecen. Imágenes viejas se acumulan. Backups suman.

Señales: Alertas de disco >80%, contenedores que no inician.

2. RAM insuficiente

Más usuarios = más conexiones = más memoria por contenedor.

Señales: Contenedores reiniciando (OOM killed), docker stats mostrando >90% de memoria.

3. La DB necesita su propio servidor

Cuando PostgreSQL y la API compiten por I/O.

Señales: Query times subiendo, docker stats mostrando I/O wait.

Solución: Mover el contenedor de Postgres a un segundo EC2, o migrar a RDS.

Comandos útiles del día a día

# Ver estado de los contenedores
docker compose ps

# Reiniciar todo
docker compose restart

# Reiniciar solo la API
docker compose restart api

# Ver logs en tiempo real
docker compose logs -f

# Entrar al contenedor de la DB
docker exec -it finanzas-db psql -U usuario -d finanzas

# Entrar al contenedor de la API
docker exec -it finanzas-api sh

# Rebuild sin cache (cuando algo está raro)
docker compose build --no-cache api
docker compose up -d api
Enter fullscreen mode Exit fullscreen mode

Conclusión

Docker añade una capa, pero es una capa que paga su costo. Deploys reproducibles, ambientes idénticos, y la tranquilidad de que docker compose up -d va a funcionar igual hoy que en 6 meses.

El setup completo: ~$25 USD/mes. Tres contenedores. Un servidor. Cero magia.

La complejidad se agrega cuando duele. Y con Docker Compose en un solo EC2, hay mucho espacio antes de que duela.

Top comments (1)

Collapse
 
jes97 profile image
Jesus

Puedes eliminar el nginx y configurar https directamente desde NestJS. Eso te evita un tercer contenedor consumiendo recursos.