Implante um aplicativo Next.js 15 App Router como um contêiner Docker no AWS ECS (Fargate) ou Kubernetes (EKS). Este guia aborda a containerização do seu aplicativo com output: "standalone", push para ECR, orquestração com ECS ou EKS, auto-escalonamento e a solução de problemas que o Vercel tratava invisivelmente -- compartilhamento de cache ISR, otimização de imagem, implantações de preview e rollouts sem downtime.
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
Quando usar isso: Sua equipe precisa de infraestrutura nativa da AWS, a conformidade exige auto-hospedagem, você precisa de controle granular sobre rede e escalonamento, ou você já executa ECS/EKS para outros serviços.
Build multi-stage com três estágios: deps instala apenas dependências de produção, builder compila o aplicativo Next.js e runner copia apenas a saída standalone em uma imagem mínima.
Pré-requisito -- defina output: "standalone" na sua configuração Next.js:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone",};export default nextConfig;
Dockerfile Completo:
# -----------------------------------------------------------# Estágio 1: deps -- instala apenas dependências de produção# -----------------------------------------------------------FROM node:20-alpine AS depsRUN apk add --no-cache libc6-compatWORKDIR /app# Copia o lockfile primeiro para que esta camada seja cacheada, a menos que as dependências mudemCOPY package.json package-lock.json ./RUN npm ci --omit=dev# -----------------------------------------------------------# Estágio 2: builder -- compila o aplicativo Next.js# -----------------------------------------------------------FROM node:20-alpine AS builderWORKDIR /app# Copia TODOS os node_modules (incluindo devDependencies) para o buildCOPY package.json package-lock.json ./RUN npm ciCOPY . .# Variáveis de ambiente em tempo de build (NEXT_PUBLIC_*) são compiladas aqui# ARG NEXT_PUBLIC_API_URL# ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLRUN npm run build# -----------------------------------------------------------# Estágio 3: runner -- imagem mínima de produção# -----------------------------------------------------------FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=production# CRÍTICO: standalone se vincula ao localhost por padrão.# Contêineres devem se vincular a 0.0.0.0 para aceitar tráfego da# rede Docker / ALB / serviço Kubernetes.ENV HOSTNAME="0.0.0.0"ENV PORT=3000# Instala sharp para otimização de next/image em produçãoRUN npm install --prefix /app sharp# Não executar como rootRUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjs# Copia o servidor standalone e os ativos estáticosCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./public# Define a propriedade corretaRUN chown -R nextjs:nodejs /appUSER nextjsEXPOSE 3000# A saída standalone produz server.js -- isso substitui `next start`CMD ["node", "server.js"]
.dockerignore -- mantenha o contexto de build pequeno:
Valores simples vão em environment (visíveis no console). Valores sensíveis vão em secrets -- obtidos do AWS Secrets Manager ou SSM Parameter Store no início do contêiner:
Variáveis NEXT_PUBLIC_* são compiladas no momento do build. Elas são incorporadas ao bundle JavaScript durante o next build. Alterá-las na sua definição de tarefa ou ConfigMap não tem efeito -- o bundle do cliente já contém o valor antigo. Se você precisar de valores diferentes por ambiente (staging vs. produção), você deve construir uma imagem Docker separada por ambiente ou usar injeção em tempo de execução (uma tag <script> que define window.__ENV e uma função auxiliar que a lê).
Crie um manipulador de rota que tanto o health check do ECS quanto os probes do Kubernetes possam acessar:
// 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 Readiness vs. Liveness (Kubernetes):
Probe de Readiness -- "Este pod está pronto para receber tráfego?" Falha durante a inicialização ou sobrecarga temporária. O Kubernetes remove o pod dos endpoints do Serviço, mas não o reinicia.
Probe de Liveness -- "Este pod está vivo?" Falha se o processo estiver travado ou bloqueado. O Kubernetes encerra e reinicia o pod.
Ambos podem acessar /api/health, mas em produção você pode tornar o probe de liveness mais simples (apenas retornar 200) e o probe de readiness mais completo (verificar conectividade com o banco de dados).
A logConfiguration da definição de tarefa (mostrada na Etapa 3) envia todo o stdout/stderr do contêiner para o CloudWatch. Cada console.log em um Server Component, Route Handler ou Server Action aparece no grupo de logs /ecs/myapp.
EKS: stdout para um agregador de logs
O Kubernetes captura o stdout/stderr do contêiner. Instale o FluentBit como um DaemonSet para enviar logs para o CloudWatch, Datadog ou sua plataforma preferida:
# Configuração simplificada do 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
Para onde vão os logs do Next.js?
console.log em Server Components, Route Handlers, Server Actions e Middleware vão para o stdout do contêiner (o processo do servidor).
console.log em Client Components vai para as DevTools do navegador.
Para produção, considere pino para logging JSON estruturado -- CloudWatch e Datadog analisam logs estruturados muito mais eficazmente do que texto puro.
Este é o maior problema para equipes que migram do Vercel.
No Vercel, o ISR "simplesmente funciona" porque o Vercel gerencia um cache compartilhado globalmente. Em uma implantação de contêiner, cada contêiner tem seu próprio .next/cache em um sistema de arquivos efêmero. Quando o contêiner A revalida uma página, os contêineres B e C ainda servem a versão desatualizada até que eles independentemente revalidem.
Soluções, do mais simples ao mais robusto:
Sessões persistentes no ALB -- Direcione cada usuário para o mesmo contêiner via cookie. Simples de configurar, mas anula o propósito do balanceamento de carga e cria pontos de acesso congestionados.
Montagem EFS compartilhada para .next/cache -- No ECS Fargate, monte um volume EFS em .next/cache. Todos os contêineres compartilham o mesmo sistema de arquivos. Adiciona ~1-5ms de latência por leitura de cache, mas é operacionalmente simples.
Manipulador de cache personalizado (recomendado) -- Aponte o cache ISR para Redis ou S3 usando a configuração cacheHandler do Next.js:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone", cacheHandler: require.resolve("./cache-handler.mjs"), cacheMaxMemorySize: 0, // Desabilita o cache em memória, usa apenas o 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 revalidação baseada em tags escaneando chaves for (const tag of [tags].flat()) { const keys = await client.keys(`*:tag:${tag}:*`); if (keys.length > 0) { await client.del(keys); } } }}
Aceitar stale-while-revalidate por contêiner -- Se a inconsistência leve for tolerável (cada contêiner revalida independentemente dentro da janela ISR), você pode pular o cache compartilhado inteiramente. A página ficará no máximo revalidate segundos desatualizada em qualquer contêiner.
Sem output: "standalone", sua imagem Docker deve incluir todo o diretório node_modules/ -- facilmente 500MB+ para um aplicativo Next.js típico. Com o modo standalone, o Next.js rastreia os arquivos exatos necessários pelo servidor e os copia para .next/standalone/, produzindo um diretório autônomo com seu próprio ponto de entrada server.js.
O que o standalone inclui:
server.js -- um servidor Node.js mínimo (substitui next start)
Um node_modules/ podado com apenas os pacotes que o servidor precisa em tempo de execução
Seu código de servidor compilado
O que o standalone NÃO inclui (você deve copiá-los separadamente):
.next/static/ -- bundles JavaScript/CSS do lado do cliente (servidos pelo servidor Node.js ou um CDN)
public/ -- ativos estáticos
É por isso que o Dockerfile tem essas duas linhas COPY extras:
Defina minimumHealthyPercent: 100 e maximumPercent: 200 na configuração de implantação. Durante uma implantação, o ECS inicia novas tarefas (até 2x o número desejado) e espera que elas passem nos health checks antes de desativar as tarefas antigas.
Linha do tempo da implantação:
t=0 [old-1] [old-2] ← 2 tarefas em execução
t=30s [old-1] [old-2] [new-1] [new-2] ← 4 tarefas, novas iniciando
t=90s [old-1] [old-2] [new-1✓] [new-2✓] ← novas tarefas passam no health check
t=120s [new-1✓] [new-2✓] ← tarefas antigas desativadas, concluídas
EKS (Kubernetes):
Defina maxSurge: 1 e maxUnavailable: 0 na estratégia de atualização gradual (rolling update). O Kubernetes cria um novo pod, espera que seu probe de readiness passe, então termina um pod antigo. Repita até que todos os pods sejam atualizados.
Ambas as plataformas: Configure o atraso de desregistro do ALB (drenagem de conexão) para permitir que as requisições em andamento sejam concluídas antes que o contêiner antigo seja parado. Um valor de 30 segundos funciona para a maioria dos aplicativos Next.js.
Armadilhas comuns ao executar Next.js em contêineres. Cada uma delas afetou pelo menos uma equipe que migrou do Vercel.
O cache ISR é por contêiner. A principal surpresa para migrantes do Vercel. Cada contêiner revalida independentemente as páginas ISR. Sem um backend de cache compartilhado (Redis, S3 ou EFS), usuários acessando contêineres diferentes veem versões inconsistentes da mesma página ISR. Veja o mergulho profundo "ISR em Contêineres" acima.
Variáveis NEXT_PUBLIC_* são compiladas no momento do docker build. Essas variáveis são incorporadas ao bundle JavaScript do lado do cliente durante o build. Alterá-las na sua definição de tarefa ECS ou ConfigMap do Kubernetes não tem efeito -- o bundle já contém os valores antigos. Ou construa imagens separadas por ambiente ou injete valores em tempo de execução via tag <script>.
Health checks de contêiner devem usar a porta correta. Os health checks do ECS acessam localhost:3000 dentro do contêiner. Se você alterar a variável de ambiente PORT, atualize o comando do health check para corresponder: curl -f http://localhost:${PORT}/api/health.
Esquecer HOSTNAME=0.0.0.0. O servidor standalone do Next.js se vincula ao localhost (127.0.0.1) por padrão. Dentro de um contêiner, isso significa que ele só aceita conexões de dentro do próprio contêiner. O ALB ou o serviço Kubernetes não conseguem alcançá-lo. Defina HOSTNAME="0.0.0.0" para que o servidor escute em todas as interfaces de rede.
Sistema de arquivos efêmero. Contêineres Fargate não têm disco persistente. Uploads de arquivos armazenados no sistema de arquivos local, arquivos de cache ISR e arquivos temporários são perdidos quando o contêiner reinicia ou é substituído durante uma implantação. Use S3 para armazenamento de arquivos, EFS para sistema de arquivos compartilhado ou um cache externo para ISR.
Cold starts no Fargate. Baixar uma imagem Docker de 200MB no Fargate leva de 10 a 30 segundos. Combine isso com o tempo de inicialização do Node.js e você terá latência perceptível de cold start. Mantenha as imagens pequenas (standalone + Alpine = ~100MB). Use ECR na mesma região do seu cluster Fargate. Considere capacidade provisionada para serviços sensíveis à latência.
sharp não instalado para otimização de imagem. O next/image requer o pacote sharp para otimização de imagem em produção. A saída standalone nem sempre o inclui. Instale explicitamente o sharp no estágio runner do seu Dockerfile (RUN npm install sharp) ou defina NEXT_SHARP_PATH para apontar para uma cópia instalada.
Não definir limites de recursos. Um build Next.js pode consumir mais de 2GB de RAM, e até mesmo o runtime pode ter picos sob carga. Sem limites de memória na sua definição de tarefa ou especificação de pod, um processo descontrolado pode privar outros contêineres no mesmo host. Defina NODE_OPTIONS=--max-old-space-size=1536 para um contêiner de 2GB para deixar margem para o sistema operacional.
Saída de log não estruturada por padrão.console.log produz texto puro. CloudWatch, Datadog e outros agregadores de logs analisam JSON estruturado muito mais eficazmente. Use pino ou um logger estruturado semelhante em Server Components e Route Handlers para obter logs pesquisáveis e filtráveis com níveis de log, IDs de requisição e tempos.
Sim, o ISR funciona em contêineres -- os temporizadores revalidate disparam e as páginas são regeneradas sob demanda. A questão é que cada contêiner tem seu próprio cache. Sem um backend de cache compartilhado (Redis, S3 ou EFS), contêineres diferentes servem versões diferentes da mesma página ISR. Para a maioria dos aplicativos, a correção mais simples é um manipulador de cache personalizado com backend Redis. Veja o mergulho profundo "ISR em Contêineres" acima.
Como lidar com implantações de preview sem Vercel?
Duas abordagens comuns: (1) Implante um serviço ECS separado ou namespace Kubernetes por branch de PR, cada um com seu próprio grupo de destino ALB e um subdomínio como pr-123.preview.example.com. (2) Use um único ambiente de staging com feature flags -- o PR ativa uma flag, e a implantação de staging mostra o novo código para os testadores. A abordagem 1 oferece isolamento real, mas custa mais. A abordagem 2 é mais barata, mas requer um sistema de feature flags.
E quanto à otimização de imagem com next/image?
O next/image funciona em contêineres -- ele usa a biblioteca sharp para redimensionar e otimizar imagens em tempo real. A desvantagem é que a otimização usa a CPU do contêiner. Para sites de alto tráfego, descarregue a otimização de imagem para CloudFront com Lambda@Edge, ou use um proxy de imagem dedicado como Imgproxy. Você também pode pré-otimizar imagens no momento do build usando next/image com loader definido como uma função personalizada.
Como fazer rollback de uma implantação ruim?
ECS: Cada implantação cria uma nova revisão da definição de tarefa. Para fazer rollback, atualize o serviço para usar a revisão anterior: aws ecs update-service --cluster my-cluster --service myapp --task-definition myapp:42 (onde 42 é o número da revisão anterior). EKS:kubectl rollout undo deployment/myapp. Ambas as abordagens são quase instantâneas porque a imagem Docker anterior já está em cache no ECR.
Como alcançar implantações zero-downtime?
ECS: Defina minimumHealthyPercent: 100 e maximumPercent: 200 na configuração de implantação do serviço. O ECS inicia novas tarefas ao lado das antigas, espera que os health checks passem, então drena as tarefas antigas. EKS: Defina maxSurge: 1 e maxUnavailable: 0 na estratégia de atualização gradual do Deployment. Habilite o atraso de desregistro do ALB (30s) em ambas as plataformas para que as requisições em andamento sejam concluídas antes que os contêineres antigos parem.
Qual a diferença entre ECS e EKS?
ECS (Elastic Container Service) é um orquestrador de contêineres nativo da AWS. Você define tarefas e serviços. O modo Fargate é serverless -- sem instâncias EC2 para gerenciar. É mais simples de aprender e operar. EKS (Elastic Kubernetes Service) executa o Kubernetes padrão. Você obtém todo o ecossistema Kubernetes (Helm, Istio, ArgoCD, etc.) e portabilidade entre nuvens. O EKS é mais complexo, mas mais flexível. Escolha ECS se você está apenas na AWS e quer simplicidade. Escolha EKS se você precisa de recursos do Kubernetes, portabilidade multi-cloud, ou sua equipe já conhece Kubernetes.
Eu preciso de Kubernetes?
Não. Para a maioria das implantações Next.js, o ECS Fargate é mais simples e suficiente. Você obtém auto-escalonamento, implantações graduais, health checks e integração com ALB sem precisar aprender Kubernetes. Escolha EKS apenas se sua organização já usa Kubernetes, você precisa do seu ecossistema (service mesh, GitOps, operadores personalizados) ou você quer portabilidade de nuvem.
Quanto custa executar contêineres na AWS em comparação com Vercel?
Depende da escala. Uma configuração mínima de ECS Fargate (2 tarefas, 0.5 vCPU, 1GB RAM cada) custa aproximadamente $30-50/mês. Adicione ALB ($20/mês + transferência de dados) e ECR ($1-5/mês). Total: ~$50-75/mês para um aplicativo pequeno. Vercel Pro custa $20/mês por assento, mas pode disparar com tráfego alto (excedentes de largura de banda, invocações de função). Em alta escala, contêineres geralmente são mais baratos. Em baixa escala, Vercel é mais barato e exige muito menos trabalho operacional.
Como lidar com WebSockets ou conexões de longa duração?
O ALB suporta conexões WebSocket nativamente. Defina o tempo limite de inatividade no ALB para corresponder à sua conexão esperada mais longa (padrão 60s, máximo 4000s). Para ECS, certifique-se de que o grupo de segurança da sua tarefa permita o tráfego. Para EKS, o ALB Ingress Controller suporta WebSocket por padrão. Note que sessões persistentes podem ser necessárias se o seu servidor WebSocket mantiver estado em memória.
Posso usar middleware em uma implantação de contêiner?
Sim, o middleware é executado dentro do runtime Node.js do contêiner em cada requisição. No Vercel, o middleware é executado na borda em um V8 isolate com uma superfície de API limitada. Em um contêiner, o middleware é executado em Node.js completo, então você tem acesso a todas as APIs do Node.js. A desvantagem é a latência -- o middleware de borda do Vercel é executado mais perto do usuário, enquanto o middleware de contêiner é executado na região do contêiner. Para latência global, coloque CloudFront na frente do ALB.
Qual a melhor forma de configurar um domínio personalizado com HTTPS?
Solicite um certificado TLS gratuito da AWS Certificate Manager (ACM) para seu domínio. Anexe-o ao listener do ALB na porta 443. Crie um registro DNS CNAME ou alias apontando seu domínio para o nome DNS do ALB. O ALB termina o TLS -- o tráfego entre o ALB e seus contêineres é HTTP na porta 3000 dentro da VPC, o que é aceitável para a maioria dos casos de uso.
Qual a melhor forma de lidar com conexões de banco de dados em contêineres?
Cada processo de contêiner abre seu próprio pool de conexões de banco de dados. Com auto-escalonamento, você pode facilmente esgotar as conexões do banco de dados. Use um pooler de conexões como PgBouncer (para PostgreSQL) ou RDS Proxy (gerenciado pela AWS). Defina seu tamanho de pool conservadoramente -- para uma implantação de 2 contêineres com max_connections: 20 cada, isso totaliza 40 conexões. Monitore a contagem de conexões no CloudWatch e escale o banco de dados antes de atingir o limite.