Tema
Despliegue
Tu API funciona en tu computadora. Ahora vas a ponerla en internet para que tu cliente y su frontend puedan usarla. Verás tres caminos: plataformas administradas (Render y Railway), y tu propio VPS con PM2 y Nginx. Para más detalle de servidores revisa Servidores y despliegue.
Concepto
| Opción | Ventajas | Desventajas | Ideal para |
|---|---|---|---|
| Render / Railway (PaaS) | Despliegue con git push, HTTPS automático, sin administrar servidores | Costo crece con el uso; disco efímero; "duermen" en planes gratis | MVPs, clientes pequeños y medianos |
| VPS (DigitalOcean, Hetzner, Linode, Contabo) | Control total, precio fijo bajo, varias apps en un servidor | Tú administras seguridad, actualizaciones y respaldos | Varios proyectos, más control |
| Contenedores (Docker en VPS, Fly.io) | Mismo entorno en todas partes | Curva de aprendizaje | Equipos y proyectos grandes |
Preparar la app para producción
Antes de desplegar, revisa:
- Puerto desde variable de entorno: las plataformas asignan el puerto.
js
const PORT = process.env.PORT ?? 3000
app.listen(PORT, '0.0.0.0', () => console.log(`Escuchando en ${PORT}`))- Scripts en
package.json:
json
{
"scripts": {
"start": "node src/index.js",
"build": "prisma generate",
"db:deploy": "prisma migrate deploy"
},
"engines": { "node": ">=22" }
}- Ruta de salud para que la plataforma sepa si la app está viva:
js
app.get('/api/salud', (req, res) => res.json({ ok: true, uptime: process.uptime() }))- Cierre ordenado (graceful shutdown): al reiniciar, termina las peticiones en curso y cierra la base de datos.
js
// src/index.js
import app from './app.js'
import { prisma } from './db/prisma.js'
const PORT = process.env.PORT ?? 3000
const servidor = app.listen(PORT, () => console.log(`API en puerto ${PORT}`))
async function apagar(senal) {
console.log(`${senal} recibido, cerrando...`)
servidor.close(async () => {
await prisma.$disconnect()
process.exit(0)
})
// Si tarda demasiado, forzar
setTimeout(() => process.exit(1), 10_000).unref()
}
process.on('SIGTERM', apagar)
process.on('SIGINT', apagar)
// Errores no capturados: registrar y reiniciar limpio (PM2/plataforma lo levanta)
process.on('unhandledRejection', (err) => {
console.error('Promesa no manejada:', err)
apagar('unhandledRejection')
})NODE_ENV=production,trust proxy,helmet, CORS con el dominio real del frontend.
Opción 1: Render
- Sube tu código a GitHub (sin
.env). - En render.com → New → Web Service → conecta el repositorio.
- Configura:
- Runtime: Node
- Build Command:
npm ci && npm run build - Start Command:
npm run db:deploy && npm start - Health Check Path:
/api/salud
- En Environment agrega:
NODE_ENV=production,DATABASE_URL,JWT_SECRET,CORS_ORIGIN... - Base de datos: New → PostgreSQL en Render y copia la Internal Database URL a
DATABASE_URL.
Cada git push a la rama principal despliega automáticamente.
También puedes declararlo en código con un archivo render.yaml (Blueprint):
yaml
# render.yaml
services:
- type: web
name: mi-api
runtime: node
plan: starter
buildCommand: npm ci && npm run build
startCommand: npm run db:deploy && npm start
healthCheckPath: /api/salud
envVars:
- key: NODE_ENV
value: production
- key: JWT_SECRET
generateValue: true
- key: DATABASE_URL
fromDatabase:
name: mi-db
property: connectionString
databases:
- name: mi-db
plan: basic-256mbPlan gratuito
Los servicios gratis de Render se "duermen" tras 15 minutos sin tráfico y tardan ~1 minuto en despertar. Para un cliente real usa un plan de pago.
Opción 2: Railway
- En railway.com → New Project → Deploy from GitHub repo.
- Agrega una base de datos: + New → Database → PostgreSQL / MySQL / MongoDB.
- En las variables de tu servicio, crea
DATABASE_URLhaciendo referencia a la variable de la base de datos (se escribe así en el panel de Railway):
txt
DATABASE_URL=${{Postgres.DATABASE_URL}}- En Settings: Start Command
npm run db:deploy && npm start, Healthcheck Path/api/salud. - Settings → Networking → Generate Domain para obtener una URL pública, o conecta tu dominio.
Con la CLI:
bash
npm i -g @railway/cli
railway login
railway link
railway up # despliega la carpeta actual
railway logsOpción 3: VPS con PM2 y Nginx
Arquitectura:
txt
Internet ──► Nginx (:80/:443, HTTPS) ──► Node con PM2 (:3000) ──► PostgreSQL/MySQL1. Preparar el servidor (Ubuntu 24.04)
bash
# Conéctate
ssh root@IP_DEL_SERVIDOR
# Usuario sin privilegios para la app
adduser deploy
usermod -aG sudo deploy
su - deploy
# Actualizar e instalar herramientas
sudo apt update && sudo apt upgrade -y
sudo apt install -y git nginx ufw
# Firewall: sólo SSH y web
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
# Node con nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 22
npm i -g pm22. Subir la app
bash
cd ~
git clone https://github.com/tu-usuario/mi-api.git
cd mi-api
npm ci --omit=dev
nano .env # variables de producción (aquí sí se crea el archivo, sólo en el servidor)
npx prisma migrate deploy3. PM2: mantener la app viva
PM2 reinicia la app si se cae, la arranca al reiniciar el servidor, guarda logs y puede usar todos los núcleos.
js
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'mi-api',
script: 'src/index.js',
instances: 'max', // un proceso por núcleo (modo clúster)
exec_mode: 'cluster',
node_args: '--env-file=.env',
env: { NODE_ENV: 'production', PORT: 3000 },
max_memory_restart: '300M',
out_file: './logs/out.log',
error_file: './logs/error.log',
time: true
}
]
}bash
pm2 start ecosystem.config.cjs
pm2 status # ver procesos
pm2 logs mi-api # ver logs en vivo
pm2 monit # CPU y memoria
pm2 reload mi-api # reinicio sin caída (zero downtime)
# Arrancar automáticamente al reiniciar el servidor
pm2 startup # copia y ejecuta el comando que imprime
pm2 save4. Nginx como proxy inverso
nginx
# /etc/nginx/sites-available/mi-api
server {
listen 80;
server_name api.midominio.com;
client_max_body_size 10M; # para subidas de archivos
location / {
proxy_pass http://127.0.0.1:3000;
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;
# WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}bash
sudo ln -s /etc/nginx/sites-available/mi-api /etc/nginx/sites-enabled/
sudo nginx -t # verifica la sintaxis
sudo systemctl reload nginxEn tu proveedor de DNS crea un registro A: api.midominio.com → IP_DEL_SERVIDOR.
5. HTTPS gratis con Let's Encrypt
bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.midominio.com
# Renovación automática ya queda configurada; pruébala:
sudo certbot renew --dry-run6. Script de actualización
bash
#!/usr/bin/env bash
# deploy.sh — ejecútalo en el servidor para publicar la última versión
set -e
cd ~/mi-api
git pull origin main
npm ci --omit=dev
npx prisma migrate deploy
pm2 reload ecosystem.config.cjs
echo "✅ Desplegado $(git rev-parse --short HEAD)"bash
chmod +x deploy.sh
./deploy.shPuedes automatizarlo con GitHub Actions conectándote por SSH (por ejemplo con la acción appleboy/ssh-action).
Docker (opcional)
dockerfile
# Dockerfile
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npx prisma generate
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"]bash
docker build -t mi-api .
docker run -d -p 3000:3000 --env-file .env --name mi-api mi-apiDespués de desplegar
- Logs y errores: Sentry o Better Stack para recibir alertas de errores.
- Monitoreo de disponibilidad: UptimeRobot o Better Stack revisando
/api/saludcada minuto. - Respaldos diarios de la base de datos (los servicios administrados lo ofrecen; en VPS con
pg_dump/mysqldump+ cron).
Buena práctica
Mantén un entorno de staging (copia de producción con otra base de datos) para probar antes de publicar cambios importantes al cliente.
Error común
Escuchar en localhost dentro de Docker o en algunas plataformas: la app no es accesible desde fuera. Escucha en 0.0.0.0 (o deja que Express use su valor por defecto).
Seguridad en VPS
Desactiva el login de root por SSH y usa llaves en vez de contraseñas (PermitRootLogin no y PasswordAuthentication no en /etc/ssh/sshd_config). Nunca expongas el puerto de la base de datos a internet.
Ejercicios
- Escribe el
ecosystem.config.cjspara dos apps en el mismo VPS:api-tienda(puerto 3000, 2 instancias) yapi-blog(puerto 3001, 1 instancia). - Escribe la configuración de Nginx para servir ambas en
api.tienda.comyapi.blog.com.
Ver solución
js
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'api-tienda',
cwd: '/home/deploy/api-tienda',
script: 'src/index.js',
instances: 2,
exec_mode: 'cluster',
node_args: '--env-file=.env',
env: { NODE_ENV: 'production', PORT: 3000 }
},
{
name: 'api-blog',
cwd: '/home/deploy/api-blog',
script: 'src/index.js',
instances: 1,
node_args: '--env-file=.env',
env: { NODE_ENV: 'production', PORT: 3001 }
}
]
}nginx
server {
listen 80;
server_name api.tienda.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name api.blog.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Después: sudo nginx -t && sudo systemctl reload nginx y sudo certbot --nginx -d api.tienda.com -d api.blog.com.
Resumen
- Prepara la app:
PORTde entorno, ruta de salud, cierre ordenado ynpm start. - Render/Railway: conectas GitHub, configuras variables y cada push despliega.
- VPS: Node con nvm + PM2 (reinicios, clúster, logs) + Nginx (proxy inverso) + Certbot (HTTPS).
- Monitorea errores y disponibilidad, y respalda la base de datos.