BytesAI
Tutorials

Deploying a Next.js 15 App to AWS ECS with Docker

A production-minded guide to containerizing a Next.js 15 app with standalone output and running it on AWS ECS Fargate — Dockerfile, task definition, and the gotchas that bite in production.

Abid Zaidi4 min read
#nextjs#aws#ecs#docker#deployment#devops
Deploying a Next.js 15 App to AWS ECS with Docker

Vercel is the path of least resistance for Next.js, and for most projects it is the right call. But sometimes you need to run inside your own AWS account — to sit next to a private database, satisfy compliance requirements, or consolidate infrastructure. This guide walks through deploying a Next.js 15 app to ECS Fargate with Docker, the way I have done it for client work.

We will focus on the parts that are specific to Next.js and the parts that quietly break in production.

Step 1: Enable standalone output

The single most important change is telling Next.js to produce a self-contained build. In next.config.ts:

typescript
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

With standalone, Next traces exactly which files and node_modules your server needs and copies them into .next/standalone. Your final image can be a fraction of the size of one that bundles the whole dependency tree.

Step 2: A multi-stage Dockerfile

Multi-stage builds keep the runtime image small and free of build tooling:

dockerfile
# 1. Install dependencies only when needed
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
# 2. Build the app
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
# 3. Minimal production image
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
 
RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Note the last three COPY lines. Standalone output does not include public/ or .next/static — you have to copy them in yourself. Forgetting .next/static is the classic "my site loads but has no CSS or JS" bug.

Step 3: Build and push to ECR

Create a repository and push:

bash
aws ecr create-repository --repository-name bytes-web
 
aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin \
    <account>.dkr.ecr.us-east-1.amazonaws.com
 
docker build -t bytes-web .
docker tag bytes-web:latest \
  <account>.dkr.ecr.us-east-1.amazonaws.com/bytes-web:latest
docker push <account>.dkr.ecr.us-east-1.amazonaws.com/bytes-web:latest

If you build on an Apple Silicon Mac and deploy to Fargate, build for the right architecture or your task will crash-loop:

bash
docker build --platform linux/amd64 -t bytes-web .

Step 4: The ECS task definition

The task definition is where Next.js apps most often go wrong. A minimal, working container definition:

json
{
  "family": "bytes-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "<account>.dkr.ecr.us-east-1.amazonaws.com/bytes-web:latest",
      "portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
      "environment": [{ "name": "PORT", "value": "3000" }],
      "healthCheck": {
        "command": ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/ || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 20
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/bytes-web",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "web"
        }
      }
    }
  ]
}

Put this behind an Application Load Balancer with a target group pointing at port 3000, and the ALB health check hitting /.

The gotchas that bite in production

These are the ones I have actually been burned by:

  1. Server env vars vs. build-time env vars. Anything prefixed NEXT_PUBLIC_ is inlined at build time, so it must be present during docker build, not just at runtime. Server-only secrets should come from the task definition (or Secrets Manager) at runtime — never bake them into the image.
  2. The health check startPeriod. Next.js needs a moment to boot. Without a startPeriod, ECS kills the task before it is ready and you get an endless deploy loop.
  3. output: standalone and the copy steps. Say it again: copy public/ and .next/static explicitly. Nothing errors — the page just renders unstyled.
  4. Right-size the task. A Next.js server idles comfortably in 512 CPU / 1024 MB, but a build-heavy startup or image optimization can spike memory. Watch CloudWatch for the first few days.

Was it worth it versus Vercel?

For most projects, no — Vercel's zero-config deploys and edge network are hard to beat, and you should reach for it by default. But when the app has to live inside a VPC next to private resources, this ECS setup is stable, boring, and fully under your control. And "boring and under your control" is exactly what you want production infrastructure to be.

How I Actually Use LLMs Day to Day as a Developer
AI Coding
A grounded look at where large language models earn their keep in a working developer's day — the tasks worth delegating, the ones worth keeping, and the workflow that ties them together.
3 min read
ChatGPT for Developers: Getting Real Work Done
ChatGPT
Beyond autocomplete — how to use ChatGPT as a genuine engineering tool for debugging, design review, and grinding through the tedious parts of a project without shipping subtle bugs.
3 min read
Claude Code vs Cursor: Which AI Coding Tool Fits Your Workflow
Comparisons
A practical comparison of Claude Code and Cursor based on real day-to-day use — where each one shines, where it gets in the way, and how to decide which belongs in your workflow.
4 min read