Despliega una aplicación Next.js 15 App Router como contenedor Docker en AWS ECS (Fargate) o Kubernetes (EKS). Esta guía te guía a través de containerizar tu aplicación con output: "standalone", enviando a ECR, orquestando con ECS o EKS, escalado automático y resolviendo problemas que Vercel manejaba invisiblemente -- compartir caché ISR, optimización de imágenes, despliegues de vista previa y despliegues sin tiempo de inactividad.
docker build -t myapp .docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestdocker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestkubectl set image deployment/myapp myapp=123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
Cuándo usarlo: Tu equipo necesita infraestructura nativa de AWS, el cumplimiento requiere auto-hospedaje, necesitas control granular sobre redes y escalado, o ya estás ejecutando ECS/EKS para otros servicios.
Construcción multi-etapa con tres etapas: deps instala solo las dependencias de producción, builder compila la aplicación Next.js, y runner copia solo la salida standalone a una imagen mínima.
Requisito previo -- configura output: "standalone" en tu config de Next.js:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone",};export default nextConfig;
Dockerfile completo:
# -----------------------------------------------------------# Stage 1: deps -- instala solo las dependencias de producción# -----------------------------------------------------------FROM node:20-alpine AS depsRUN apk add --no-cache libc6-compatWORKDIR /app# Copia el lockfile primero para que esta capa se almacene en caché a menos que cambien las depsCOPY package.json package-lock.json ./RUN npm ci --omit=dev# -----------------------------------------------------------# Stage 2: builder -- construye la aplicación Next.js# -----------------------------------------------------------FROM node:20-alpine AS builderWORKDIR /app# Copia TODO node_modules (incluyendo devDependencies) para la construcciónCOPY package.json package-lock.json ./RUN npm ciCOPY . .# Las variables de entorno de construcción (NEXT_PUBLIC_*) se incrustan aquí# ARG NEXT_PUBLIC_API_URL# ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLRUN npm run build# -----------------------------------------------------------# Stage 3: runner -- imagen de producción mínima# -----------------------------------------------------------FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=production# CRÍTICO: standalone se vincula a localhost por defecto.# Los contenedores deben vincularse a 0.0.0.0 para aceptar tráfico desde# la red Docker / ALB / servicio Kubernetes.ENV HOSTNAME="0.0.0.0"ENV PORT=3000# Instala sharp para optimización de next/image en producciónRUN npm install --prefix /app sharp# No ejecutar como rootRUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjs# Copia el servidor standalone y los activos estáticosCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./public# Establece la propiedad correctaRUN chown -R nextjs:nodejs /appUSER nextjsEXPOSE 3000# La salida standalone produce server.js -- esto reemplaza `next start`CMD ["node", "server.js"]
.dockerignore -- mantén el contexto de construcción pequeño:
Los valores simples van en environment (visibles en la consola). Los valores sensibles van en secrets -- extraídos de AWS Secrets Manager o SSM Parameter Store al iniciar el contenedor:
Las variables NEXT_PUBLIC_ se embeben en tiempo de construcción. Estas se incorporan al bundle de JavaScript durante next build. Cambiarlas en tu definición de tarea o ConfigMap no tiene efecto -- el bundle del cliente ya contiene el valor anterior. Si necesitas valores diferentes por entorno (staging vs. producción), debes construir una imagen Docker separada por entorno o usar inyección en tiempo de ejecución (una etiqueta <script> que establezca window.__ENV y un helper que lea desde ella).
Crea un route handler que puedan usar las verificaciones de salud de ECS y los probes de Kubernetes:
// app/api/health/route.tsimport { NextResponse } from "next/server";export const dynamic = "force-dynamic";export function GET() { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), version: process.env.APP_VERSION ?? "unknown", uptime: process.uptime(), });}
Probes de preparación vs. actividad (Kubernetes):
Probe de preparación -- "¿Está este pod listo para recibir tráfico?" Falla durante el inicio o sobrecarga temporal. Kubernetes elimina el pod de los endpoints del servicio pero no lo reinicia.
Probe de actividad -- "¿Está este pod vivo?" Falla si el proceso está en deadlock o colgado. Kubernetes mata e reinicia el pod.
Ambos pueden golpear /api/health, pero en producción podrías hacer el probe de actividad más simple (solo devolver 200) y el probe de preparación más exhaustivo (verificar conectividad de base de datos).
ECS: CloudWatch Logs mediante el controlador awslogs
La logConfiguration de la definición de tarea (mostrada en el Paso 3) envía todo stdout/stderr del contenedor a CloudWatch. Cada console.log en un Server Component, Route Handler o Server Action aparece en el grupo de registros /ecs/myapp.
EKS: stdout a un agregador de registros
Kubernetes captura stdout/stderr del contenedor. Instala FluentBit como DaemonSet para enviar registros a CloudWatch, Datadog o tu plataforma preferida:
# Configuración simplificada de salida de FluentBit para CloudWatch[OUTPUT] Name cloudwatch_logs Match * region us-east-1 log_group_name /eks/myapp log_stream_prefix pod- auto_create_group On
¿Dónde van los registros de Next.js?
console.log en Server Components, Route Handlers, Server Actions e Middleware todo va a stdout (el proceso del servidor).
console.log en Client Components va a DevTools del navegador.
Para producción, considera pino para registros JSON estructurados -- CloudWatch y Datadog analizan registros estructurados mucho más efectivamente que texto plano.
Esta es la sorpresa más grande para equipos que migran de Vercel.
En Vercel, ISR "simplemente funciona" porque Vercel gestiona una caché compartida global. En un despliegue de contenedor, cada contenedor tiene su propio .next/cache en un sistema de archivos efímero. Cuando el contenedor A revalida una página, los contenedores B y C todavía sirven la versión obsoleta hasta que se revalidan de forma independiente.
Soluciones, de más simple a más robusta:
Sesiones pegajosas en el ALB -- Enruta cada usuario al mismo contenedor mediante una cookie. Simple de configurar, pero anula el propósito del equilibrio de carga y crea puntos calientes.
Montaje EFS compartido para .next/cache -- En ECS Fargate, monta un volumen EFS en .next/cache. Todos los contenedores comparten el mismo sistema de archivos. Agrega ~1-5ms de latencia por lectura de caché pero es operativamente simple.
Controlador de caché personalizado (recomendado) -- Apunta la caché ISR a Redis o S3 usando la configuración cacheHandler de Next.js:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone", cacheHandler: require.resolve("./cache-handler.mjs"), cacheMaxMemorySize: 0, // Deshabilita caché en memoria, usa solo externo};export default nextConfig;
// cache-handler.mjsimport { createClient } from "redis";const client = createClient({ url: process.env.REDIS_URL });await client.connect();export default class CacheHandler { async get(key) { const data = await client.get(key); return data ? JSON.parse(data) : null; } async set(key, data, ctx) { const ttl = ctx.revalidate ?? 60; await client.set(key, JSON.stringify(data), { EX: ttl }); } async revalidateTag(tags) { // Implementa revalidación basada en etiquetas escaneando claves for (const tag of [tags].flat()) { const keys = await client.keys(`*:tag:${tag}:*`); if (keys.length > 0) { await client.del(keys); } } }}
Acepta stale-while-revalidate por contenedor -- Si la inconsistencia leve es tolerable (cada contenedor se revalida de forma independiente dentro de la ventana ISR), puedes omitir completamente el almacenamiento en caché compartida. La página estará como máximo revalidate segundos obsoleta en cualquier contenedor dado.
Sin output: "standalone", tu imagen Docker debe incluir todo el directorio node_modules/ -- fácilmente 500MB+ para una aplicación Next.js típica. Con el modo standalone, Next.js rastrea los archivos exactos necesarios por el servidor y los copia a .next/standalone/, produciendo un directorio autónomo con su propio punto de entrada server.js.
Lo que standalone incluye:
server.js -- un servidor de Node.js mínimo (reemplaza next start)
Un node_modules/ podado con solo los paquetes que el servidor necesita en tiempo de ejecución
Tu código compilado del lado del servidor
Lo que standalone NO incluye (debes copiarlos por separado):
.next/static/ -- bundles de JS/CSS del lado del cliente (servidos por el servidor de Node.js o un CDN)
public/ -- activos estáticos
Por eso el Dockerfile tiene estas dos líneas COPY extra:
Configura minimumHealthyPercent: 100 y maximumPercent: 200 en la configuración de despliegue. Durante un despliegue, ECS inicia nuevas tareas (hasta 2x el recuento deseado) y espera a que pasen verificaciones de salud antes de drenar las tareas antiguas.
Cronograma de despliegue:
t=0 [old-1] [old-2] ← 2 tareas en ejecución
t=30s [old-1] [old-2] [new-1] [new-2] ← 4 tareas, las nuevas iniciando
t=90s [old-1] [old-2] [new-1✓] [new-2✓] ← las nuevas tareas pasan verificación de salud
t=120s [new-1✓] [new-2✓] ← tareas antiguas drenadas, hecho
EKS (Kubernetes):
Configura maxSurge: 1 y maxUnavailable: 0 en la estrategia de actualización gradual. Kubernetes crea un nuevo pod, espera a que su probe de preparación pase, luego termina un pod antiguo. Repite hasta que todos los pods se actualicen.
Ambas plataformas: Configura el retraso de desregistro de ALB (drenaje de conexión) para permitir que las solicitudes en vuelo se completen antes de que el contenedor antiguo se detenga. Un valor de 30 segundos funciona para la mayoría de aplicaciones Next.js.
Errores comunes al ejecutar Next.js en contenedores. Cada uno ha atrapado al menos a un equipo que migra de Vercel.
La caché ISR es por contenedor. La sorpresa número uno para migrantes de Vercel. Cada contenedor revalida de forma independiente las páginas ISR. Sin una caché compartida (Redis, S3, EFS), los usuarios que golpean diferentes contenedores ven versiones inconsistentes de la misma página. Consulta el análisis profundo "ISR en Contenedores" anterior.
Las variables NEXT_PUBLIC_ se embeben en tiempo docker build. Estas variables se incorporan al bundle de JavaScript del lado del cliente durante la construcción. Cambiarlas en tu definición de tarea de ECS o ConfigMap de Kubernetes no tiene efecto -- el bundle ya contiene los valores antiguos. Ya sea construye imágenes separadas por entorno o inyecta valores en tiempo de ejecución mediante una etiqueta <script>.
Las verificaciones de salud del contenedor deben usar el puerto correcto. Las verificaciones de salud de ECS golpean localhost:3000 dentro del contenedor. Si cambias la variable de entorno PORT, actualiza el comando de verificación de salud para que coincida: curl -f http://localhost:${PORT}/api/health.
Olvidar HOSTNAME=0.0.0.0. El servidor standalone de Next.js se vincula a localhost (127.0.0.1) por defecto. Dentro de un contenedor, eso significa que solo acepta conexiones desde dentro del contenedor mismo. El ALB o servicio de Kubernetes no puede alcanzarlo. Configura HOSTNAME="0.0.0.0" para que el servidor escuche en todas las interfaces de red.
Sistema de archivos efímero. Los contenedores Fargate no tienen disco persistente. Las cargas de archivos almacenadas en el sistema de archivos local, archivos de caché ISR y archivos temporales se pierden cuando el contenedor se reinicia o se reemplaza durante un despliegue. Usa S3 para almacenamiento de archivos, EFS para sistema de archivos compartido, o una caché externa para ISR.
Arranques en frío en Fargate. Extraer una imagen Docker de 200MB en Fargate toma 10-30 segundos. Combina eso con el tiempo de inicio de Node.js y obtienes latencia de arranque en frío notable. Mantén las imágenes pequeñas (standalone + Alpine = ~100MB). Usa ECR en la misma región que tu clúster Fargate. Considera capacidad aprovisionada para servicios sensibles a la latencia.
sharp no instalado para optimización de imágenes.next/image requiere el paquete sharp para optimización de imágenes de producción. La salida standalone no siempre lo incluye. Instala explícitamente sharp en la etapa runner de tu Dockerfile (RUN npm install sharp) o configura NEXT_SHARP_PATH para que apunte a una copia instalada.
No establecer límites de recursos. Una construcción de Next.js puede consumir 2GB+ de RAM, e incluso el runtime puede dispararse bajo carga. Sin límites de memoria en tu definición de tarea o especificación de pod, un proceso fugitivo puede desnutrir otros contenedores en el mismo host. Configura NODE_OPTIONS=--max-old-space-size=1536 para un contenedor de 2GB dejando espacio libre para el SO.
La salida de registro está sin estructura por defecto.console.log produce texto plano. CloudWatch, Datadog y otros agregadores de registros analizan JSON estructurado mucho más efectivamente. Usa pino o un logger estructurado similar en Server Components y Route Handlers para obtener registros buscables y filtrables con niveles de registro, IDs de solicitud y tiempos.
Sí, ISR funciona en contenedores -- los temporizadores revalidate se disparan y las páginas se regeneran bajo demanda. La trampa es que cada contenedor tiene su propia caché. Sin un backend de caché compartida (Redis, S3 o EFS), diferentes contenedores sirven diferentes versiones de la misma página ISR. Para la mayoría de aplicaciones, la solución más simple es un controlador de caché respaldado por Redis. Consulta el análisis profundo "ISR en Contenedores" anterior.
¿Cómo manejo despliegues de vista previa sin Vercel?
Dos enfoques comunes: (1) Despliega un servicio ECS o namespace Kubernetes separado por rama PR, cada uno con su propio grupo objetivo de ALB y un subdominio como pr-123.preview.example.com. (2) Usa un entorno de staging único con banderas de características -- el PR activa una bandera, y el despliegue de staging muestra el código nuevo a los probadores. El enfoque 1 da verdadero aislamiento pero cuesta más. El enfoque 2 es más barato pero requiere un sistema de banderas de características.
¿Qué hay sobre optimización de imágenes con next/image?
next/image funciona en contenedores -- usa la librería sharp para redimensionar y optimizar imágenes sobre la marcha. El trade-off es que la optimización usa CPU del contenedor. Para sitios de alto tráfico, descarga la optimización de imágenes a CloudFront con Lambda@Edge, o usa un proxy de imágenes dedicado como Imgproxy. También puedes pre-optimizar imágenes en tiempo de construcción usando next/image con loader establecido en una función personalizada.
¿Cómo revierto un despliegue fallido?
ECS: Cada despliegue crea una nueva revisión de definición de tarea. Para revertir, actualiza el servicio para usar la revisión anterior: aws ecs update-service --cluster my-cluster --service myapp --task-definition myapp:42 (donde 42 es el número de revisión anterior). EKS:kubectl rollout undo deployment/myapp. Ambos enfoques son casi instantáneos porque la imagen Docker anterior ya está almacenada en caché en ECR.
¿Cómo logro despliegues sin tiempo de inactividad?
ECS: Configura minimumHealthyPercent: 100 y maximumPercent: 200 en la configuración de despliegue del servicio. ECS inicia nuevas tareas junto a las antiguas, espera a que pasen verificaciones de salud, luego drena las tareas antiguas. EKS: Configura maxSurge: 1 y maxUnavailable: 0 en la estrategia de actualización gradual del Despliegue. Habilita retraso de desregistro de ALB (30s) en ambas plataformas para que las solicitudes en vuelo se completen antes de que se detengan los contenedores antiguos.
¿Cuál es la diferencia entre ECS y EKS?
ECS (Elastic Container Service) es orquestación de contenedores nativa de AWS. Defines tareas y servicios. El modo Fargate es serverless -- sin instancias EC2 que administrar. Es más simple de aprender y operar. EKS (Elastic Kubernetes Service) ejecuta Kubernetes estándar. Obtienes el ecosistema completo de Kubernetes (Helm, Istio, ArgoCD, etc.) y portabilidad entre nubes. EKS es más complejo pero más flexible. Elige ECS si eres solo AWS y deseas simplicidad. Elige EKS si necesitas características de Kubernetes, portabilidad multi-nube, o tu equipo ya conoce Kubernetes.
¿Necesito Kubernetes?
No. Para la mayoría de despliegues de Next.js, ECS Fargate es más simple y suficiente. Obtienes escalado automático, despliegues gradualmente, verificaciones de salud e integración de ALB sin aprender Kubernetes. Elige EKS solo si tu organización ya usa Kubernetes, necesitas su ecosistema (malla de servicios, GitOps, operadores personalizados), o deseas portabilidad entre nubes.
¿Cuánto cuesta ejecutar contenedores en AWS en comparación con Vercel?
Depende de la escala. Un setup mínimo de ECS Fargate (2 tareas, 0.5 vCPU, 1GB RAM cada una) cuesta aproximadamente $30-50/mes. Agrega ALB ($20/mes + transferencia de datos) y ECR ($1-5/mes). Total: ~$50-75/mes para una aplicación pequeña. Vercel Pro es $20/mes por puesto pero puede dispararse con tráfico alto (sobrecargos de ancho de banda, invocaciones de función). En escala alta, los contenedores son generalmente más baratos. En escala baja, Vercel es más barato y mucho menos trabajo operativo.
¿Cómo manejo WebSockets o conexiones de larga vida?
ALB soporta conexiones WebSocket de forma nativa. Configura el tiempo de espera de inactividad en el ALB para que coincida con tu conexión más larga esperada (defecto 60s, máximo 4000s). Para ECS, asegúrate de que el grupo de seguridad de tu tarea permite el tráfico. Para EKS, el Controlador de ALB Ingress soporta WebSocket por defecto. Ten en cuenta que las sesiones pegajosas pueden ser necesarias si tu servidor de WebSocket mantiene estado en memoria.
¿Puedo usar middleware en un despliegue de contenedor?
Sí, middleware se ejecuta dentro del runtime de Node.js del contenedor en cada solicitud. En Vercel, middleware se ejecuta en el edge en un aislado V8 con una superficie de API limitada. En un contenedor, middleware se ejecuta en Node.js completo, así que obtienes acceso a todas las APIs de Node.js. El trade-off es latencia -- el middleware edge de Vercel se ejecuta más cerca del usuario, mientras que el middleware del contenedor se ejecuta en la región del contenedor. Para latencia global, pon CloudFront frente al ALB.
¿Cómo configuro un dominio personalizado con HTTPS?
Solicita un certificado TLS gratuito de AWS Certificate Manager (ACM) para tu dominio. Adjúntalo al oyente de ALB en el puerto 443. Crea un registro CNAME o alias de DNS apuntando tu dominio al nombre DNS del ALB. El ALB termina TLS -- el tráfico entre el ALB y tus contenedores es HTTP en el puerto 3000 dentro del VPC, que está bien para la mayoría de casos de uso.
¿Cuál es la mejor manera de manejar conexiones de base de datos en contenedores?
Cada proceso de contenedor abre su propio pool de conexión de base de datos. Con escalado automático, puedes agotar fácilmente las conexiones de base de datos. Usa un agrupador de conexiones como PgBouncer (para PostgreSQL) o RDS Proxy (gestionado por AWS). Configura el tamaño de tu pool conservadoramente -- para un despliegue de 2 contenedores con max_connections: 20 cada uno, eso es 40 conexiones totales. Monitorea el recuento de conexiones en CloudWatch y escala la base de datos antes de alcanzar el límite.