Deploy a Next.js 15 App Router application as a Docker container on AWS ECS (Fargate) or Kubernetes (EKS). This guide walks through containerizing your app with output: "standalone", pushing to ECR, orchestrating with ECS or EKS, auto-scaling, and solving the problems Vercel handled invisibly -- ISR cache sharing, image optimization, preview deployments, and zero-downtime rollouts.
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
When to reach for this: Your team needs AWS-native infrastructure, compliance requires self-hosting, you need fine-grained control over networking and scaling, or you are already running ECS/EKS for other services.
Multi-stage build with three stages: deps installs only production dependencies, builder compiles the Next.js app, and runner copies just the standalone output into a minimal image.
Prerequisite -- set output: "standalone" in your Next.js config:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone",};export default nextConfig;
Complete Dockerfile:
# -----------------------------------------------------------# Stage 1: deps -- install production dependencies only# -----------------------------------------------------------FROM node:20-alpine AS depsRUN apk add --no-cache libc6-compatWORKDIR /app# Copy lockfile first so this layer is cached unless deps changeCOPY package.json package-lock.json ./RUN npm ci --omit=dev# -----------------------------------------------------------# Stage 2: builder -- build the Next.js application# -----------------------------------------------------------FROM node:20-alpine AS builderWORKDIR /app# Copy ALL node_modules (including devDependencies) for the buildCOPY package.json package-lock.json ./RUN npm ciCOPY . .# Build-time env vars (NEXT_PUBLIC_*) are baked in here# ARG NEXT_PUBLIC_API_URL# ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLRUN npm run build# -----------------------------------------------------------# Stage 3: runner -- minimal production image# -----------------------------------------------------------FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=production# CRITICAL: standalone binds to localhost by default.# Containers must bind to 0.0.0.0 to accept traffic from# the Docker network / ALB / Kubernetes service.ENV HOSTNAME="0.0.0.0"ENV PORT=3000# Install sharp for next/image optimization in productionRUN npm install --prefix /app sharp# Don't run as rootRUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjs# Copy the standalone server and static assetsCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./public# Set correct ownershipRUN chown -R nextjs:nodejs /appUSER nextjsEXPOSE 3000# standalone output produces server.js -- this replaces `next start`CMD ["node", "server.js"]
Plain values go in environment (visible in the console). Sensitive values go in secrets -- pulled from AWS Secrets Manager or SSM Parameter Store at container start:
NEXT_PUBLIC_ variables are baked at build time. These are inlined into the JavaScript bundle during next build. Changing them in your task definition or ConfigMap has no effect -- the client bundle already contains the old value. If you need different values per environment (staging vs. production), you must either build a separate Docker image per environment or use runtime injection (a <script> tag that sets window.__ENV and a helper that reads from it).
Create a route handler that both ECS health checks and Kubernetes probes can hit:
// 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(), });}
Readiness vs. liveness probes (Kubernetes):
Readiness probe -- "Is this pod ready to receive traffic?" Fails during startup or temporary overload. Kubernetes removes the pod from the Service endpoints but does not restart it.
Liveness probe -- "Is this pod alive?" Fails if the process is deadlocked or hung. Kubernetes kills and restarts the pod.
Both can hit /api/health, but in production you might make the liveness probe simpler (just return 200) and the readiness probe more thorough (check database connectivity).
The task definition's logConfiguration (shown in Step 3) sends all container stdout/stderr to CloudWatch. Every console.log in a Server Component, Route Handler, or Server Action appears in the log group /ecs/myapp.
EKS: stdout to a log aggregator
Kubernetes captures container stdout/stderr. Install FluentBit as a DaemonSet to ship logs to CloudWatch, Datadog, or your preferred platform:
# Simplified FluentBit output config for CloudWatch[OUTPUT] Name cloudwatch_logs Match * region us-east-1 log_group_name /eks/myapp log_stream_prefix pod- auto_create_group On
Where do Next.js logs go?
console.log in Server Components, Route Handlers, Server Actions, and Middleware all go to container stdout (the server process).
console.log in Client Components goes to the browser DevTools.
For production, consider pino for structured JSON logging -- CloudWatch and Datadog parse structured logs far more effectively than plain text.
This is the single biggest surprise for teams migrating from Vercel.
On Vercel, ISR "just works" because Vercel manages a globally shared cache. In a container deployment, each container has its own .next/cache on an ephemeral filesystem. When container A revalidates a page, containers B and C still serve the stale version until they independently revalidate.
Solutions, from simplest to most robust:
Sticky sessions on the ALB -- Route each user to the same container via a cookie. Simple to configure, but defeats the purpose of load balancing and creates hot spots.
Shared EFS mount for .next/cache -- On ECS Fargate, mount an EFS volume at .next/cache. All containers share the same filesystem. Adds ~1-5ms latency per cache read but is operationally simple.
Custom cache handler (recommended) -- Point the ISR cache to Redis or S3 using the Next.js cacheHandler configuration:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone", cacheHandler: require.resolve("./cache-handler.mjs"), cacheMaxMemorySize: 0, // Disable in-memory cache, use external only};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) { // Implement tag-based revalidation by scanning keys for (const tag of [tags].flat()) { const keys = await client.keys(`*:tag:${tag}:*`); if (keys.length > 0) { await client.del(keys); } } }}
Accept stale-while-revalidate per container -- If slight inconsistency is tolerable (each container revalidates independently within the ISR window), you can skip shared caching entirely. The page will be at most revalidate seconds stale on any given container.
Without output: "standalone", your Docker image must include the entire node_modules/ directory -- easily 500MB+ for a typical Next.js app. With standalone mode, Next.js traces the exact files needed by the server and copies them into .next/standalone/, producing a self-contained directory with its own server.js entrypoint.
What standalone includes:
server.js -- a minimal Node.js server (replaces next start)
A pruned node_modules/ with only the packages the server needs at runtime
Your compiled server-side code
What standalone does NOT include (you must copy them separately):
.next/static/ -- client-side JS/CSS bundles (served by the Node.js server or a CDN)
public/ -- static assets
That is why the Dockerfile has these two extra COPY lines:
Set minimumHealthyPercent: 100 and maximumPercent: 200 in the deployment configuration. During a deploy, ECS starts new tasks (up to 2x desired count) and waits for them to pass health checks before draining the old tasks.
Deploy timeline:
t=0 [old-1] [old-2] ← 2 tasks running
t=30s [old-1] [old-2] [new-1] [new-2] ← 4 tasks, new ones starting
t=90s [old-1] [old-2] [new-1✓] [new-2✓] ← new tasks pass health check
t=120s [new-1✓] [new-2✓] ← old tasks drained, done
EKS (Kubernetes):
Set maxSurge: 1 and maxUnavailable: 0 in the rolling update strategy. Kubernetes creates one new pod, waits for its readiness probe to pass, then terminates one old pod. Repeat until all pods are updated.
Both platforms: Configure ALB deregistration delay (connection draining) to allow in-flight requests to complete before the old container is stopped. A value of 30 seconds works for most Next.js apps.
Common pitfalls when running Next.js in containers. Each one has bitten at least one team migrating from Vercel.
ISR cache is per-container. The number-one surprise for Vercel migrants. Each container independently revalidates ISR pages. Without a shared cache (Redis, S3, EFS), users hitting different containers see inconsistent versions of the same page. See the "ISR in Containers" deep dive above.
NEXT_PUBLIC_ vars are baked at docker build time. These variables are inlined into the client-side JavaScript bundle during the build. Changing them in your ECS task definition or Kubernetes ConfigMap has zero effect -- the bundle already contains the old values. Either build separate images per environment or inject values at runtime via a <script> tag.
Container health checks must use the right port. ECS health checks hit localhost:3000 inside the container. If you change the PORT environment variable, update the health check command to match: curl -f http://localhost:${PORT}/api/health.
Forgetting HOSTNAME=0.0.0.0. The Next.js standalone server binds to localhost (127.0.0.1) by default. Inside a container, that means it only accepts connections from inside the container itself. The ALB or Kubernetes service cannot reach it. Set HOSTNAME="0.0.0.0" so the server listens on all network interfaces.
Ephemeral filesystem. Fargate containers have no persistent disk. File uploads stored to the local filesystem, ISR cache files, and temporary files are all lost when the container restarts or is replaced during a deploy. Use S3 for file storage, EFS for shared filesystem, or an external cache for ISR.
Cold starts on Fargate. Pulling a 200MB Docker image on Fargate takes 10-30 seconds. Combine that with Node.js startup time and you get noticeable cold start latency. Keep images small (standalone + Alpine = ~100MB). Use ECR in the same region as your Fargate cluster. Consider provisioned capacity for latency-sensitive services.
sharp not installed for image optimization.next/image requires the sharp package for production image optimization. The standalone output does not always include it. Explicitly install sharp in the runner stage of your Dockerfile (RUN npm install sharp) or set NEXT_SHARP_PATH to point to an installed copy.
Not setting resource limits. A Next.js build can consume 2GB+ of RAM, and even the runtime can spike under load. Without memory limits in your task definition or pod spec, one runaway process can starve other containers on the same host. Set NODE_OPTIONS=--max-old-space-size=1536 for a 2GB container to leave headroom for the OS.
Log output is unstructured by default.console.log produces plain text. CloudWatch, Datadog, and other log aggregators parse structured JSON far more effectively. Use pino or a similar structured logger in Server Components and Route Handlers to get searchable, filterable logs with log levels, request IDs, and timing.
Yes, ISR works in containers -- revalidate timers fire and pages regenerate on demand. The catch is that each container has its own cache. Without a shared cache backend (Redis, S3, or EFS), different containers serve different versions of the same ISR page. For most apps, the simplest fix is a Redis-backed custom cache handler. See the "ISR in Containers" deep dive above.
How do I handle preview deployments without Vercel?
Two common approaches: (1) Deploy a separate ECS service or Kubernetes namespace per PR branch, each with its own ALB target group and a subdomain like pr-123.preview.example.com. (2) Use a single staging environment with feature flags -- the PR toggles a flag, and the staging deploy shows the new code to testers. Approach 1 gives true isolation but costs more. Approach 2 is cheaper but requires a feature flag system.
What about image optimization with next/image?
next/image works in containers -- it uses the sharp library to resize and optimize images on the fly. The trade-off is that optimization uses container CPU. For high-traffic sites, offload image optimization to CloudFront with Lambda@Edge, or use a dedicated image proxy like Imgproxy. You can also pre-optimize images at build time using next/image with loader set to a custom function.
How do I roll back a bad deployment?
ECS: Every deployment creates a new task definition revision. To roll back, update the service to use the previous revision: aws ecs update-service --cluster my-cluster --service myapp --task-definition myapp:42 (where 42 is the previous revision number). EKS:kubectl rollout undo deployment/myapp. Both approaches are near-instant because the previous Docker image is already cached in ECR.
How do I achieve zero-downtime deploys?
ECS: Set minimumHealthyPercent: 100 and maximumPercent: 200 in the service deployment configuration. ECS starts new tasks alongside old ones, waits for health checks to pass, then drains old tasks. EKS: Set maxSurge: 1 and maxUnavailable: 0 in the Deployment rolling update strategy. Enable ALB deregistration delay (30s) on both platforms so in-flight requests complete before old containers stop.
What is the difference between ECS and EKS?
ECS (Elastic Container Service) is AWS-native container orchestration. You define tasks and services. Fargate mode is serverless -- no EC2 instances to manage. It is simpler to learn and operate. EKS (Elastic Kubernetes Service) runs standard Kubernetes. You get the full Kubernetes ecosystem (Helm, Istio, ArgoCD, etc.) and portability across clouds. EKS is more complex but more flexible. Choose ECS if you are AWS-only and want simplicity. Choose EKS if you need Kubernetes features, multi-cloud portability, or your team already knows Kubernetes.
Do I need Kubernetes?
No. For most Next.js deployments, ECS Fargate is simpler and sufficient. You get auto-scaling, rolling deployments, health checks, and ALB integration without learning Kubernetes. Choose EKS only if your organization already uses Kubernetes, you need its ecosystem (service mesh, GitOps, custom operators), or you want cloud portability.
How much does running containers on AWS cost compared to Vercel?
It depends on scale. A minimal ECS Fargate setup (2 tasks, 0.5 vCPU, 1GB RAM each) costs roughly $30-50/month. Add ALB ($20/month + data transfer) and ECR ($1-5/month). Total: ~$50-75/month for a small app. Vercel Pro is $20/month per seat but can spike with high traffic (bandwidth overages, function invocations). At high scale, containers are usually cheaper. At low scale, Vercel is cheaper and far less operational work.
How do I handle WebSockets or long-lived connections?
ALB supports WebSocket connections natively. Set the idle timeout on the ALB to match your longest expected connection (default 60s, max 4000s). For ECS, ensure your task's security group allows the traffic. For EKS, the ALB Ingress Controller supports WebSocket by default. Note that sticky sessions may be needed if your WebSocket server maintains in-memory state.
Can I use middleware in a container deployment?
Yes, middleware runs inside the container's Node.js runtime on every request. On Vercel, middleware runs at the edge in a V8 isolate with a limited API surface. In a container, middleware runs in full Node.js, so you get access to all Node.js APIs. The trade-off is latency -- Vercel edge middleware runs closer to the user, while container middleware runs in the container's region. For global latency, put CloudFront in front of the ALB.
How do I set up a custom domain with HTTPS?
Request a free TLS certificate from AWS Certificate Manager (ACM) for your domain. Attach it to the ALB listener on port 443. Create a CNAME or alias DNS record pointing your domain to the ALB's DNS name. The ALB terminates TLS -- traffic between the ALB and your containers is HTTP on port 3000 inside the VPC, which is fine for most use cases.
What is the best way to handle database connections in containers?
Each container process opens its own database connection pool. With auto-scaling, you can easily exhaust database connections. Use a connection pooler like PgBouncer (for PostgreSQL) or RDS Proxy (managed by AWS). Set your pool size conservatively -- for a 2-container deployment with max_connections: 20 each, that is 40 connections total. Monitor connection count in CloudWatch and scale the database before you hit the limit.