Arquitectura de servicios
WEB · API
nutriflow-api
Fastify + Node.js 20. Auto-deploy desde
main. Health check en /api/healthWEB · STATIC
nutriflow-frontend
React 18 + Vite SPA. Cache inmutable para assets, no-cache para index.html
WORKER
pdf-email-worker
Generación de informes PDF y envío de emails. Consume cola Redis
CRON
daily-maintenance
Limpieza de sesiones + agregación de métricas. Ejecuta a las 03:00 UTC
DATABASE
nutriflow-db
PostgreSQL 16 gestionada. IP allowlist vacía = acceso solo desde Render
REDIS
nutriflow-redis
Cache de sesiones + cola de jobs. Conexión interna entre servicios
FLUJO DE DEPLOY
GitHub main
→
Render Build
→
Docker Multi-stage
→
Health Check /api/health
→
api.nutriflow.app
PR abierto
→
Preview environment
→
pr-123.nutriflow-api.onrender.com
render.yaml
# render.yaml — NutriFlow SaaS · Infraestructura completa como código # CULTIVA IA · deploy en Render.com desde repositorio GitHub envVarGroups: - name: shared-config envVars: - key: LOG_LEVEL value: info - key: CORS_ORIGIN value: https://app.nutriflow.app - key: SENTRY_DSN sync: false # Configurar manualmente en dashboard - key: NODE_ENV value: production services: # ── API Web Service ────────────────────────────────────────────── - type: web name: nutriflow-api runtime: docker dockerfilePath: ./Dockerfile region: frankfurt # EU region para cumplimiento GDPR plan: standard # 2 vCPU · 2 GB RAM autoDeploy: true # Deploy automático en push a main branch: main healthCheckPath: /api/health numInstances: 1 scaling: minInstances: 1 maxInstances: 4 targetCPUPercent: 65 targetMemoryPercent: 70 customDomains: - domain: api.nutriflow.app # SSL auto via Let's Encrypt envVars: - fromGroup: shared-config - key: DATABASE_URL fromDatabase: name: nutriflow-db property: connectionString - key: REDIS_URL fromService: name: nutriflow-redis type: redis property: connectionString - key: JWT_SECRET generateValue: true # Render genera valor aleatorio seguro - key: PORT value: "3000" - key: PDF_BUCKET_URL sync: false # Configurar con S3/R2 compatible URL # ── Background Worker (PDF + Email) ────────────────────────────── - type: worker name: pdf-email-worker runtime: docker dockerfilePath: ./Dockerfile.worker region: frankfurt plan: starter envVars: - fromGroup: shared-config - key: DATABASE_URL fromDatabase: name: nutriflow-db property: connectionString - key: REDIS_URL fromService: name: nutriflow-redis type: redis property: connectionString - key: RESEND_API_KEY sync: false - key: WORKER_CONCURRENCY value: "5" # ── Frontend React SPA ─────────────────────────────────────────── - type: web name: nutriflow-frontend runtime: static buildCommand: cd frontend && npm ci && npm run build staticPublishPath: frontend/dist branch: main customDomains: - domain: app.nutriflow.app headers: - path: /* name: Cache-Control value: public, max-age=31536000, immutable - path: /index.html name: Cache-Control value: no-cache - path: /* name: X-Frame-Options value: DENY routes: - type: rewrite source: /* destination: /index.html # SPA fallback para React Router # ── Cron: Mantenimiento Diario ─────────────────────────────────── - type: cron name: daily-maintenance runtime: node buildCommand: npm ci startCommand: npm run maintenance schedule: "0 3 * * *" # 03:00 UTC cada día region: frankfurt envVars: - key: DATABASE_URL fromDatabase: name: nutriflow-db property: connectionString - key: TASKS value: cleanup-sessions,aggregate-metrics,purge-pdfs databases: - name: nutriflow-db plan: standard databaseName: nutriflow_prod postgresMajorVersion: 16 region: frankfurt ipAllowList: [] # Solo acceso desde servicios Render user: nutriflow_admin - name: nutriflow-redis plan: starter region: frankfurt maxmemoryPolicy: allkeys-lru # Expira claves más antiguas en pico
Dockerfile
# ── Stage 1: Builder ────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app # Copiar solo package.json para cachear dependencias COPY package*.json ./ RUN npm ci # Copiar código fuente y compilar TypeScript COPY . . RUN npm run build # ── Stage 2: Runner (imagen mínima de producción) ───────────────── FROM node:20-alpine AS runner WORKDIR /app # Usuario no-root por seguridad RUN addgroup -g 1001 -S nodejs && adduser -S nutriflow -u 1001 # Solo artefactos de producción COPY --from=builder --chown=nutriflow:nodejs /app/dist ./dist COPY --from=builder --chown=nutriflow:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nutriflow:nodejs /app/package.json ./ USER nutriflow EXPOSE 3000 ENV NODE_ENV=production # Health check embebido en imagen HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD ["node", "dist/index.js"]
scripts/deploy.ts
// scripts/deploy.ts — Control programático de servicios NutriFlow en Render // Uso: npx ts-node scripts/deploy.ts [--scale 3] [--env staging] const RENDER_API_KEY = process.env.RENDER_API_KEY!; const SERVICES: Record<string, string> = { api: "srv-nutriflow-api", worker: "srv-nutriflow-worker", frontend: "srv-nutriflow-frontend", }; /** Dispara un deploy manual en el servicio dado */ async function triggerDeploy( serviceKey: keyof typeof SERVICES, clearCache = false ): Promise<void> { const serviceId = SERVICES[serviceKey]; const res = await fetch( `https://api.render.com/v1/services/${serviceId}/deploys`, { method: "POST", headers: { Authorization: `Bearer ${RENDER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ clearCache: clearCache ? "clear" : "do_not_clear", }), } ); const deploy = await res.json(); console.log(`[${serviceKey}] Deploy iniciado: ${deploy.id} → ${deploy.status}`); } /** Escala horizontalmente la API */ async function scaleApi(instances: number): Promise<void> { if (instances < 1 || instances > 4) { throw new Error("Rango válido: 1–4 instancias (plan standard)"); } const res = await fetch( `https://api.render.com/v1/services/${SERVICES.api}/scale`, { method: "POST", headers: { Authorization: `Bearer ${RENDER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ numInstances: instances }), } ); console.log(`API escalada a ${instances} instancia(s). Status: ${res.status}`); } /** Lista deploys recientes */ async function getDeployHistory(limit = 5) { const res = await fetch( `https://api.render.com/v1/services/${SERVICES.api}/deploys?limit=${limit}`, { headers: { Authorization: `Bearer ${RENDER_API_KEY}` } } ); const deploys = await res.json(); deploys.forEach((d: any) => console.log(` ${d.createdAt} — ${d.status} (${d.id})`) ); } // CLI entrypoint const args = process.argv.slice(2); if (args.includes("--scale")) { const n = parseInt(args[args.indexOf("--scale") + 1], 10); scaleApi(n).catch(console.error); } else { triggerDeploy("api"); triggerDeploy("frontend"); }