Desplegar Next.js en un servidor Linux independiente (AWS EC2, DigitalOcean Droplet, Hetzner, etc.) usando next build && next start. Todo lo que Vercel maneja automáticamente -- CDN, SSL, escalado, despliegues de vista previa, variables de entorno -- ahora es tu responsabilidad. Esta guía te lleva a través de cada parte.
Tarjeta de referencia rápida -- lista para copiar y pegar.
# En tu servidor (Ubuntu 22.04+)# 1. Construir el bundle de producciónNODE_ENV=production npm ciNODE_ENV=production npx next build# 2. Iniciar con PM2 (gestor de procesos)npm install -g pm2pm2 start npm --name "myapp" -- startpm2 savepm2 startup# 3. Proxy inverso Nginx (después de instalar nginx)sudo apt install nginx certbot python3-certbot-nginx -ysudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/sudo nginx -t && sudo systemctl reload nginx# 4. Certificado SSLsudo certbot --nginx -d myapp.com -d www.myapp.com# 5. Verificarcurl -I https://myapp.com
Cuándo usarlo: Tu empresa requiere auto-hosting, necesitas controlar el entorno del servidor, estás desplegando en una VPC sin acceso público a internet, o quieres costos mensuales predecibles en lugar de facturación basada en el uso.
# Clonar tu repositoriogit clone https://github.com/your-org/your-app.git /home/nextjs/appcd /home/nextjs/app# Instalar dependencias de producciónnpm ci# Compilar para producciónNODE_ENV=production npx next build
Después de que la compilación se complete, el directorio .next/ contiene:
.next/├── cache/ # Caché ISR, caché de optimización de imágenes, caché de compilación├── server/ # Bundles del lado del servidor (páginas App Router, rutas API)│ ├── app/ # Páginas App Router compiladas│ ├── chunks/ # Chunks del servidor compartidos│ └── pages/ # Páginas Pages Router compiladas (si las hay)├── static/ # Bundles JS/CSS del lado del cliente (con fingerprint)│ └── chunks/ # Bundles del cliente divididos por código├── BUILD_ID # Identificador único de compilación├── build-manifest.json # Mapea rutas a bundles del cliente└── trace # Datos de rastreo de compilación
Inicia el servidor de producción para verificar:
NODE_ENV=production npx next start -p 3000# Visita http://<server-ip>:3000 para verificar, luego Ctrl+C
# .env.production# Solo del lado del servidor (no expuesto al navegador)DATABASE_URL="postgresql://user:pass@db-host:5432/mydb"NEXTAUTH_SECRET="your-secret-key-here"NEXTAUTH_URL="https://myapp.com"# Del lado del cliente (incrustado en el bundle JS en tiempo de COMPILACIÓN)NEXT_PUBLIC_API_URL="https://api.myapp.com"NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxx"
El prefijo NEXT_PUBLIC_ es crítico de entender:
Prefijo
Disponible Donde
Cuándo Se Resuelve
El Cambio Requiere
NEXT_PUBLIC_
Servidor + Cliente (navegador)
Tiempo de compilación (insertado en el bundle JS)
Recompilación
Sin prefijo
Solo servidor
Tiempo de ejecución (se lee de process.env)
Reinicio
Para variables de entorno gestionadas por PM2, usa un ecosystem.config.js:
PM2 mantiene tu proceso Node.js vivo, lo reinicia si falla, y sobrevive a los reinicios del servidor.
# Instalar PM2 globalmentenpm install -g pm2
Crea un ecosystem.config.js listo para producción con modo cluster:
// ecosystem.config.jsmodule.exports = { apps: [ { name: "myapp", script: "node_modules/.bin/next", args: "start -p 3000", cwd: "/home/nextjs/app", instances: "max", // Usa todos los núcleos de CPU disponibles exec_mode: "cluster", // Modo cluster para equilibrio de carga max_memory_restart: "512M", // Reinicia si la memoria excede 512MB env: { NODE_ENV: "production", PORT: 3000, }, // Registro error_file: "/home/nextjs/logs/err.log", out_file: "/home/nextjs/logs/out.log", log_date_format: "YYYY-MM-DD HH:mm:ss Z", merge_logs: true, }, ],};
Inicia y persiste:
# Crear directorio de registrosmkdir -p /home/nextjs/logs# Iniciar la aplicaciónpm2 start ecosystem.config.js# Guardar la lista de procesos (para que PM2 sepa qué reiniciar después del reinicio)pm2 save# Generar el script de inicio (ejecuta el comando que genera como root)pm2 startup# Copia-pega el comando generado, p.ej.:# sudo env PATH=$PATH:/home/nextjs/.nvm/versions/node/v20.x.x/bin pm2 startup systemd -u nextjs --hp /home/nextjs# Verificar que los procesos se están ejecutandopm2 ls
Crea un script de despliegue que extrae el código más reciente, recompila, y recarga gracefully:
#!/bin/bash# /home/nextjs/deploy.shset -euo pipefailAPP_DIR="/home/nextjs/app"LOG_FILE="/home/nextjs/logs/deploy-$(date +%Y%m%d-%H%M%S).log"echo "=== Deploy iniciado en $(date) ===" | tee "$LOG_FILE"cd "$APP_DIR"# Extraer código más recienteecho "Extrayendo código más reciente..." | tee -a "$LOG_FILE"git pull origin main 2>&1 | tee -a "$LOG_FILE"# Instalar dependencias (ci para instalaciones limpias)echo "Instalando dependencias..." | tee -a "$LOG_FILE"npm ci 2>&1 | tee -a "$LOG_FILE"# Compilar la aplicaciónecho "Compilando..." | tee -a "$LOG_FILE"NODE_ENV=production npx next build 2>&1 | tee -a "$LOG_FILE"# Recargar procesos PM2 gracefully (sin tiempo de inactividad)echo "Recargando procesos PM2..." | tee -a "$LOG_FILE"pm2 reload myapp 2>&1 | tee -a "$LOG_FILE"echo "=== Deploy completado en $(date) ===" | tee -a "$LOG_FILE"
¿Por qué pm2 reload en lugar de pm2 restart:
reload -- Inicia nuevos procesos worker primero, espera a que acepten conexiones, luego apaga gracefully los workers antiguos. Sin tiempo de inactividad.
restart -- Mata todos los workers inmediatamente, luego inicia otros nuevos. Breve tiempo de inactividad mientras los nuevos procesos arrancan.
Configura tu balanceador de carga o servicio de monitoreo para consultar https://myapp.com/api/health cada 30 segundos. Una respuesta 200 con "status": "ok" significa que el servidor está sano.
Configura la rotación de registros de PM2 para evitar que los registros consuman todo el espacio en disco:
# Instalar el módulo de rotación de registrospm2 install pm2-logrotate# Configurar los parámetros de rotaciónpm2 set pm2-logrotate:max_size 50M # Rotar cuando el registro alcance 50MBpm2 set pm2-logrotate:retain 30 # Mantener 30 archivos rotadospm2 set pm2-logrotate:compress true # Comprimir registros antiguos con gzippm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
Para enviar registros a un servicio centralizado:
Opción A: CloudWatch (AWS)
# Instalar el agente CloudWatchsudo apt install amazon-cloudwatch-agent -y# Configurarlo para monitorear archivos de registro de PM2# /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
La Regeneración Estática Incremental funciona lista para usar en un servidor único porque las páginas regeneradas se escriben en .next/cache/ en disco local. Cuando se revalida una página, Next.js:
Sirve la página obsoleta inmediatamente
Regenera la página en segundo plano
Escribe la nueva página en .next/cache/
Sirve la nueva página en la siguiente solicitud
El problema con múltiples servidores: Si escalas a 2+ servidores detrás de un balanceador de carga, cada servidor tiene su propio .next/cache/. El servidor A podría tener una página fresca mientras el servidor B sigue sirviendo una obsoleta. Los usuarios ven contenido inconsistente.
Soluciones:
Sesiones adhesivas -- Enruta usuarios al mismo servidor vía afinidad de sesión ALB. Más simple pero reduce la efectividad del equilibrio de carga.
Montaje NFS compartido -- Monta .next/cache/ desde un volumen EFS. Todos los servidores comparten la misma caché. Añade latencia pero asegura consistencia.
Controlador de caché personalizado -- Usa incrementalCacheHandlerPath en next.config.ts para apuntar a una caché respaldada por Redis o S3:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { cacheHandler: "./cache-handler.ts", cacheMaxMemorySize: 0, // Deshabilitar caché en memoria, usar solo almacén externo};export default nextConfig;
// cache-handler.tsimport { CacheHandler } from "next/dist/server/lib/incremental-cache";import { createClient } from "redis";const client = createClient({ url: process.env.REDIS_URL });client.connect();export default class RedisCacheHandler extends CacheHandler { async get(key: string) { const data = await client.get(key); return data ? JSON.parse(data) : null; } async set(key: string, data: unknown, ctx: { revalidate?: number }) { const ttl = ctx.revalidate ?? 60; await client.set(key, JSON.stringify(data), { EX: ttl }); } async revalidateTag(tag: string) { // Escanear claves con esta etiqueta y eliminarlas const keys = await client.keys(`*:${tag}:*`); if (keys.length > 0) { await client.del(keys); } }}
Configurar output: "standalone" en next.config.ts le indica al proceso de compilación que rastree las importaciones de tu aplicación y agrupe solo node_modules requerido en .next/standalone/:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone",};export default nextConfig;
Después de compilar, el directorio .next/standalone/ contiene:
.next/standalone/├── node_modules/ # Solo las dependencias que tu aplicación realmente usa (~50MB)├── server.js # Punto de entrada mínimo del servidor Node.js├── package.json└── .next/ └── server/ # Bundles del servidor compilados
Debes copiar manualmente activos estáticos:
# Después de compilar con output: "standalone"cp -r public .next/standalone/publiccp -r .next/static .next/standalone/.next/static
Luego inicia con:
cd .next/standaloneNODE_ENV=production node server.js
PM2 reinicia automáticamente procesos fallidos. Configura límites de memoria para atrapar fugas de memoria:
# Establecer límite de memoria (reinicia si se excede)pm2 start ecosystem.config.js # max_memory_restart ya está configurado en la config# Monitorear en tiempo realpm2 monit
Endpoint de comprobación de salud para ALB:
Configura la comprobación de salud del grupo objetivo ALB:
Ruta:/api/health
Intervalo: 30 segundos
Umbral saludable: 2 éxitos consecutivos
Umbral no saludable: 3 fallos consecutivos
Timeout: 10 segundos
Gestión del espacio en disco:
El directorio .next/cache/ puede crecer sin límites, especialmente con ISR y optimización de imágenes:
# Trabajo cron para podar caché ISR más antigua de 7 días# Agregar a crontab -e0 3 * * * find /home/nextjs/app/.next/cache -type f -mtime +7 -delete 2>/dev/null
Ajuste de memoria de Node.js:
Si tu aplicación procesa payloads o conjuntos de datos grandes, aumenta el límite de heap de V8:
// ecosystem.config.jsmodule.exports = { apps: [ { name: "myapp", script: "node_modules/.bin/next", args: "start -p 3000", node_args: "--max-old-space-size=1024", // Límite de heap de 1 GB max_memory_restart: "1200M", // Umbral de reinicio de PM2 por encima del límite de V8 // ... resto de config }, ],};
Olvidar copiar public/ y .next/static/ con salida standalone. El modo output: "standalone" agrupa solo el código del servidor. Los activos estáticos (public/, .next/static/) deben copiarse en .next/standalone/ manualmente, o Nginx servirá 404s para cada archivo CSS, JS e imagen.
Ejecutar next start como root. Si el proceso Node.js se ve comprometido, el atacante tiene acceso root. Siempre crea un usuario nextjs dedicado con permisos mínimos. PM2 se ejecuta como ese usuario, y Nginx (que necesita los puertos 80/443) se ejecuta como su propio usuario www-data.
No establecer NODE_ENV=production. Next.js omite optimizaciones críticas en modo desarrollo: sin minificación, sin eliminación de código muerto, páginas de error verbose con mapas de fuente expuestos. Siempre establece NODE_ENV=production en tu config PM2 o entorno shell antes de compilar e iniciar.
Exponer puerto 3000 directamente a internet. Nunca dejes que los usuarios lleguen directamente al proceso Node.js. Nginx proporciona terminación TLS, límite de velocidad, headers de seguridad, compresión gzip, y protección contra ataques slowloris. Tu grupo de seguridad solo debe permitir el puerto 3000 desde 127.0.0.1.
Caché ISR creciendo sin límites. En sitios de alto tráfico con muchas páginas dinámicas (p.ej., /product/[id] con 100k productos), .next/cache/fetch-cache/ e .next/cache/images/ pueden llenar el disco. Monitorea el uso de disco y establece un trabajo cron para podar entradas de caché antiguas.
Headers Upgrade faltantes en Nginx. Sin proxy_set_header Upgrade $http_upgrade y proxy_set_header Connection "upgrade", las conexiones WebSocket fallan silenciosamente. Esto afecta respuestas streaming de Server Actions, streaming de React Server Components, y HMR en modo desarrollo. La conexión parece funcionar pero los datos nunca llegan.
Renovación de Certbot no automatizada. Los certificados Let's Encrypt expiran cada 90 días. Aunque certbot configura un temporizador systemd por defecto, verifica que esté activo: sudo systemctl list-timers | grep certbot. Si el temporizador falta, añade 0 0 1 * * certbot renew --quiet a crontab de root.
Variables NEXT_PUBLIC_ insertadas en tiempo de compilación. Los desarrolladores que migran desde Vercel están acostumbrados a cambiar variables de entorno en un panel y que entren en efecto en la siguiente solicitud. En un servidor independiente, las variables NEXT_PUBLIC_ se incrustan en el bundle de JavaScript durante next build. Cambiarlas requiere una recompilación y redespliegue completos, no solo un reinicio de PM2. Las variables solo del lado del servidor (sin el prefijo NEXT_PUBLIC_) sí entran en efecto después de un reinicio.
Build fallando con OOM en instancias pequeñas.next build puede consumir 2+ GB de RAM en aplicaciones grandes. Si estás compilando en un t3.micro (1 GB RAM), añade espacio de intercambio o compila en una instancia más grande y copia el directorio .next/ encima.
Olvidar persistir .next/cache/ en despliegues. Si tu script de despliegue ejecuta rm -rf .next antes de compilar, pierdes la caché de compilación e ISR. Las compilaciones toman más tiempo, y todas las páginas ISR deben regenerarse. En su lugar, solo elimina .next/server/ y .next/static/ si es necesario, preservando .next/cache/.
¿ISR (Regeneración Estática Incremental) aún funciona en un servidor independiente?
Sí. ISR funciona lista para usar en un servidor único porque las páginas regeneradas se escriben en .next/cache/ en disco local. La única complicación es configuraciones multi-servidor donde cada servidor tiene su propia caché. En ese caso, usa sesiones adhesivas, un montaje NFS compartido (AWS EFS), o un controlador de caché personalizado respaldado por Redis o S3.
¿Cómo hago despliegues de vista previa sin Vercel?
Tienes varias opciones: (1) Ejecuta un proceso PM2 separado por rama en un puerto diferente, con Nginx enrutando por subdominio (pr-123.preview.myapp.com). (2) Usa Coolify o Dokku, que proporcionan despliegues de vista previa automáticos. (3) Omite despliegues de vista previa y confía en entornos de ensayo. La mayoría de equipos eligen la opción 3 a menos que tengan un ingeniero de DevOps dedicado.
¿Qué hay sobre optimización de imágenes? ¿`next/image` aún funciona?
Sí, next/image funciona en un servidor independiente. La diferencia es que la optimización de imágenes (redimensionamiento, conversión de formato a WebP/AVIF) se ejecuta en la CPU de tu servidor en lugar de la red de borde de Vercel. Para sitios de alto tráfico, esto puede ser intensivo en CPU. Mitigación: pon un CDN (CloudFront, Cloudflare) delante de tu servidor para cachear imágenes optimizadas, o usa la prop loader para offload a un servicio como Cloudinary o Imgix.
¿Cómo retrocedo un despliegue deficiente?
Como estás desplegando vía git pull, retrocede comprobando el commit anterior y recompilando:
cd /home/nextjs/appgit log --oneline -5 # Encontrar el último commit buenogit checkout <commit-hash> # Comprobar ese commitnpm ci && NODE_ENV=production npx next buildpm2 reload myapp
Para retrocesos más rápidos, mantén el directorio .next/ de compilación anterior como respaldo antes de cada despliegue.
¿Puedo usar Server Actions en un servidor independiente?
Sí. Server Actions funcionan idénticamente en un servidor independiente. Se ejecutan como solicitudes POST al mismo servidor Node.js. La única diferencia respecto a Vercel es que se ejecutan en un proceso Node.js de larga duración en lugar de una función sin servidor, así que sé consciente de las fugas de memoria en procesos de larga duración.
¿Necesito un balanceador de carga para un servidor único?
No. Una instancia EC2 única con Nginx como proxy inverso es suficiente. Solo necesitas un balanceador de carga (AWS ALB/NLB) cuando escalas a múltiples servidores. Sin embargo, incluso con un servidor, colocarlo detrás de un ALB te da comprobaciones de salud, terminación SSL fácil vía ACM (sin certbot necesario), y una ruta de migración más simple cuando escalas más tarde.
¿Cuánto cuesta esto comparado con Vercel?
Una instancia EC2 t3.medium (2 vCPU, 4 GB RAM) cuesta aproximadamente $30/mes con una instancia reservada o $34/mes bajo demanda. Esto puede manejar tráfico moderado que costaría $100+ en Vercel Pro. Sin embargo, estás pagando con tu tiempo por ops, monitoreo, y parches de seguridad. Para equipos pequeños, el servicio gestionado de Vercel es frecuentemente más barato cuando factorizas tiempo de ingeniería.
¿Debo usar el modo de salida standalone o el por defecto?
Usa output: "standalone" cuando despliegues en contenedores Docker o cuando quieras el artefacto de despliegue más pequeño posible (~50 MB). Usa la salida por defecto cuando despliegues con el directorio node_modules/ completo y quieras despliegues más simples (solo git pull && npm ci && next build && pm2 reload). Standalone añade un paso manual de copiar public/ y .next/static/.
Usa archivos .env.staging y .env.production separados. Next.js carga .env.production automáticamente cuando NODE_ENV=production. Para staging, o bien establece NODE_ENV=staging con una estrategia de carga de env personalizada, o usa archivos de ecosistema PM2 con bloques de env diferentes por entorno:
¿Middleware se ejecuta de la misma forma que en Vercel?
La API es idéntica, pero el tiempo de ejecución es diferente. En Vercel, Middleware se ejecuta en aislamientos de borde V8 (API Web limitada). En un servidor independiente, Middleware se ejecuta en el tiempo de ejecución completo de Node.js, lo que significa que tienes acceso a más APIs de Node.js pero pierdes el beneficio de ubicación de borde. Si tu Middleware es sensible a la latencia (p.ej., redirecciones de geolocalización), considera colocar un CDN delante de tu servidor.
¿Cómo configuro un pipeline de CI/CD para esto?
Usa GitHub Actions (o tu herramienta CI) para SSH al servidor y ejecutar el script de despliegue:
¿Qué pasa si mi compilación toma demasiado tiempo y causa tiempo de inactividad?
La compilación se ejecuta mientras la versión antigua sigue sirviendo tráfico (PM2 mantiene los procesos antiguos vivos hasta pm2 reload). No hay tiempo de inactividad durante la compilación misma. El único riesgo es si la compilación consume tanta CPU/RAM que degrada la aplicación en ejecución. Soluciones: (1) Compila en un servidor CI separado y rsync el directorio .next/ encima. (2) Usa una instancia más grande durante compilaciones. (3) Añade espacio de intercambio.