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
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) │ │ │ └───────┘ │ │
└──────────┘ │ │ │ │
│ └─────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘
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 pullydocker 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
Detalles importantes
-
restart: unless-stoppedSi el contenedor crashea, Docker lo reinicia automáticamente. Si yo lo detengo manualmente, no lo reinicia. -
depends_onconcondition: service_healthyLa API no inicia hasta que PostgreSQL esté listo para aceptar conexiones. Evita errores de conexión en el startup. -
Volumen para PostgreSQL
postgres_datapersiste 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 redinternal. 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
¿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
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
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
Y en el compose:
api:
env_file:
- .env
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;
}
}
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
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"
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/
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
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
Cron en el host:
# /etc/cron.d/db-backup
0 3 * * * root /opt/app/scripts/backup.sh
Subir a S3
# Agregar al script de backup
aws s3 cp /opt/app/backup/db_${TIMESTAMP}.dump s3://tu-bucket/backups/
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/healthcada 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
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
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)
Puedes eliminar el nginx y configurar https directamente desde NestJS. Eso te evita un tercer contenedor consumiendo recursos.