Desplegar Next.js en ECS / EKS (Contenedores)
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.
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
Dockerfile (multi-etapa de producción):
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
USER node
CMD ["node", "server.js"]Desplegar en ECS (Fargate):
docker build -t myapp .
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com
docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
aws ecs update-service --cluster my-cluster --service my-service --force-new-deploymentDesplegar en EKS (Kubernetes):
docker build -t myapp .
docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
kubectl set image deployment/myapp myapp=123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestCuá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.
Ejemplo Funcional
Un recorrido completo de despliegue listo para producción desde Dockerfile hasta servicio en ejecución.
1. El Dockerfile de Producción
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.ts
import 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 deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copia el lockfile primero para que esta capa se almacene en caché a menos que cambien las deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# -----------------------------------------------------------
# Stage 2: builder -- construye la aplicación Next.js
# -----------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
# Copia TODO node_modules (incluyendo devDependencies) para la construcción
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# 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_URL
RUN npm run build
# -----------------------------------------------------------
# Stage 3: runner -- imagen de producción mínima
# -----------------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV 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ón
RUN npm install --prefix /app sharp
# No ejecutar como root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copia el servidor standalone y los activos estáticos
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Establece la propiedad correcta
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 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:
.git
node_modules
.next
.env*
*.md
.github
.vscode
coverage2. Construir y Enviar a ECR
Crea un repositorio ECR (una sola vez), luego construye y envía tu imagen:
# Crear repositorio ECR (una sola vez)
aws ecr create-repository \
--repository-name myapp \
--region us-east-1
# Construye la imagen Docker
docker build -t myapp .
# Autentica Docker con ECR
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin \
123456789.dkr.ecr.us-east-1.amazonaws.com
# Etiqueta para ECR
docker tag myapp:latest \
123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
# Envía a ECR
docker push \
123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest3. Despliegue en ECS Fargate
Definición de Tarea
{
"family": "myapp",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "myapp",
"image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "APP_VERSION", "value": "1.0.0" },
{ "name": "NODE_OPTIONS", "value": "--max-old-space-size=768" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:myapp/DATABASE_URL"
}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"essential": true
}
]
}Configuración del Servicio
# Crear el servicio ECS con ALB
aws ecs create-service \
--cluster my-cluster \
--service-name myapp \
--task-definition myapp:1 \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[subnet-abc123,subnet-def456],
securityGroups=[sg-abc123],
assignPublicIp=ENABLED
}" \
--load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/myapp/abc123,containerName=myapp,containerPort=3000" \
--deployment-configuration "minimumHealthyPercent=100,maximumPercent=200"Política de Escalado Automático
# Registra el objetivo escalable
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/my-cluster/myapp \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 \
--max-capacity 10
# Seguimiento de destino en utilización de CPU
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/my-cluster/myapp \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu-tracking \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 70.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleOutCooldown": 60,
"ScaleInCooldown": 120
}'Configuración de ALB
Configura la verificación de salud del grupo objetivo del Application Load Balancer:
# Configura la verificación de salud del grupo objetivo de ALB
aws elbv2 modify-target-group \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/myapp/abc123 \
--health-check-path /api/health \
--health-check-interval-seconds 30 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3 \
--health-check-timeout-seconds 5
# Habilita adherencia (útil para ISR si no se utiliza caché compartida)
aws elbv2 modify-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/myapp/abc123 \
--attributes Key=stickiness.enabled,Value=true Key=stickiness.type,Value=lb_cookie Key=stickiness.lb_cookie.duration_seconds,Value=36004. Despliegue en EKS / Kubernetes
Manifiesto de Despliegue
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # Sin tiempo de inactividad: nunca eliminar un pod antes de que uno nuevo esté listo
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: NODE_OPTIONS
value: "--max-old-space-size=768"
envFrom:
- configMapRef:
name: myapp-config
- secretRef:
name: myapp-secrets
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1024Mi"
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
terminationGracePeriodSeconds: 30Manifiesto de Servicio
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
type: ClusterIP
selector:
app: myapp
ports:
- port: 80
targetPort: 3000
protocol: TCPManifiesto de Ingress (Controlador AWS ALB Ingress)
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]'
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789:certificate/abc-123
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/healthcheck-path: /api/health
alb.ingress.kubernetes.io/healthcheck-interval-seconds: "30"
alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
spec:
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
tls:
- hosts:
- myapp.example.comHorizontalPodAutoscaler
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 120
policies:
- type: Percent
value: 25
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 2
periodSeconds: 605. Variables de Entorno
ECS: environment y secrets de Definición de Tarea
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:
{
"environment": [
{ "name": "NEXT_PUBLIC_APP_NAME", "value": "MyApp" },
{ "name": "LOG_LEVEL", "value": "info" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:myapp/db-url"
},
{
"name": "AUTH_SECRET",
"valueFrom": "arn:aws:ssm:us-east-1:123456789:parameter/myapp/auth-secret"
}
]
}EKS: ConfigMap y Secret
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
LOG_LEVEL: "info"
CACHE_TTL: "3600"
---
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: myapp-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:pass@host:5432/db"
AUTH_SECRET: "super-secret-value"Las variables
NEXT_PUBLIC_se embeben en tiempo de construcción. Estas se incorporan al bundle de JavaScript durantenext 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 establezcawindow.__ENVy un helper que lea desde ella).
6. Punto Final de Verificación de Salud
Crea un route handler que puedan usar las verificaciones de salud de ECS y los probes de Kubernetes:
// app/api/health/route.ts
import { 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).
7. Tubería CI/CD
Un flujo de trabajo de GitHub Actions que construye, envía a ECR e implementa en ECS:
# .github/workflows/deploy.yml
name: Deploy to ECS
on:
push:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: myapp
ECS_CLUSTER: my-cluster
ECS_SERVICE: myapp
ECS_TASK_DEFINITION: .aws/task-definition.json
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
id: build-image
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Update ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: myapp
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: truePara EKS, reemplaza el paso de despliegue:
- name: Deploy to EKS
run: |
aws eks update-kubeconfig --name my-cluster --region $AWS_REGION
kubectl set image deployment/myapp \
myapp=${{ steps.build-image.outputs.image }}
kubectl rollout status deployment/myapp --timeout=300s8. Registros
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.logen Server Components, Route Handlers, Server Actions e Middleware todo va a stdout (el proceso del servidor).console.logen Client Components va a DevTools del navegador.- Para producción, considera
pinopara registros JSON estructurados -- CloudWatch y Datadog analizan registros estructurados mucho más efectivamente que texto plano.
// lib/logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
});Análisis Profundo
Vercel vs. Contenedores: Qué Cambia
| Preocupación | Vercel (gestionado) | ECS / EKS (auto-hospedado) |
|---|---|---|
| Almacenamiento en caché de CDN / Edge | CDN global integrado | ALB + CloudFront frente a ECS/EKS |
| Revalidación ISR | Caché compartida en todos los edges | Cada contenedor tiene su propio .next/cache -- necesita caché compartida (Redis, S3) |
| Despliegues de vista previa | URLs automáticas por PR | Servicio ECS separado por rama PR, o single deployment con banderas de características |
| Escalado automático | Escalado serverless automático | Políticas de escalado ECS o HPA de Kubernetes |
| Optimización de imágenes | next/image optimizado para edge | next/image usa CPU del contenedor; descarga a CloudFront + Lambda@Edge o Imgproxy |
| Velocidad de construcción e implementación | Tubería de construcción optimizada | Almacenamiento en caché de capas Docker, caché ECR, BuildKit |
| Despliegues previos | Un clic en el panel | ECS: reimplementar revisión anterior de definición de tarea. EKS: kubectl rollout undo |
| HTTPS | TLS automático | ALB maneja terminación TLS; ACM proporciona certificados gratuitos |
| Variables de entorno | UI del panel + cifrado | Env de definición de tarea / Secrets de Kubernetes + Secrets Manager |
| Middleware | Se ejecuta en el edge (aislado V8) | Se ejecuta dentro del runtime de Node.js del contenedor, no en el edge |
ISR en Contenedores -- El Problema de la Caché
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
cacheHandlerde Next.js:
// next.config.ts
import 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.mjs
import { 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
revalidatesegundos obsoleta en cualquier contenedor dado.
El Modo de Salida standalone
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 (reemplazanext 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:
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./publicResultado: Una imagen de producción de ~100-150MB en lugar de 500MB+.
Optimización del Tamaño de Imagen
| Técnica | Impacto |
|---|---|
| Construcción multi-etapa (3 etapas) | Solo la etapa runner termina en la imagen final |
.dockerignore | Excluye .git, node_modules, .env*, *.md del contexto de construcción |
Imagen base Alpine (node:20-alpine) | ~50MB base vs. ~350MB para node:20 (Debian) |
npm ci --omit=dev en la etapa deps | Sin devDependencies en la imagen final |
output: "standalone" | Rastreado, node_modules mínimo (~30MB vs. 300MB+) |
| Ordenamiento de capas | Dependencias primero (en caché), código fuente últimas (cambia frecuentemente) |
Despliegues Sin Tiempo de Inactividad
ECS Fargate:
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.
Trampas
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 tiempodocker 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:3000dentro del contenedor. Si cambias la variable de entornoPORT, 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 alocalhost(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. ConfiguraHOSTNAME="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.
-
sharpno instalado para optimización de imágenes.next/imagerequiere el paquetesharppara 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 configuraNEXT_SHARP_PATHpara 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=1536para un contenedor de 2GB dejando espacio libre para el SO. -
La salida de registro está sin estructura por defecto.
console.logproduce texto plano. CloudWatch, Datadog y otros agregadores de registros analizan JSON estructurado mucho más efectivamente. Usapinoo 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.
Alternativas
| Alternativa | Usarlo Cuando | No Usarlo Cuando |
|---|---|---|
| Vercel | Deseas cero ops e implementaciones instantáneas | La empresa requiere auto-hospedaje o solo AWS |
| EC2 + PM2 (standalone) | Despliegue simple de un solo servidor, tráfico bajo | Necesitas escalado automático u orquestación de contenedores |
| AWS App Runner | Quieres simplicidad de Fargate sin definiciones de tarea | Necesitas redes granulares o contenedores sidecar |
| Coolify / Railway | Quieres un PaaS con soporte de contenedores | El cumplimiento empresarial requiere servicios nativos de AWS |
| Exportación estática | Sitio completamente estático detrás de un CDN | Usas SSR, ISR, middleware o Server Actions |
FAQs
¿Funciona ISR en contenedores?
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.
Relacionado
- Despliegue -- Descripción general de opciones de despliegue de Next.js
- Desplegar en EC2 / Standalone -- Despliegue no containerizado
- Variables de Entorno -- Gestión de variables de entorno en Next.js