Containerize a Next.js App with ECR

Build a production Docker image for Next.js and push it to Amazon ECR.

Introduction

30 Second Summary

You have a working app on your laptop. Now you need to ship it so it runs exactly the same way on a server thousands of miles away. That gap between "works on my machine" and "works in production" is where containers come in.

In this project, you will package a Next.js app into a production-optimized Docker image and push it to Amazon ECR, ready for deployment to Kubernetes.

What You'll Build

A production-grade containerized Next.js application using the official Vercel Docker pattern, resulting in a ~250MB image running as a non-root user with proper environment variable handling.

By the end of this project, you'll have:

  • A multi-stage Dockerfile that separates dependency installation, building, and runtime into isolated stages for a minimal production image.
  • A Next.js 16 demo app with standalone output tracing and an API route that proves runtime environment variables work correctly inside the container.
  • Your production image pushed to an Amazon ECR private repository, ready for Kubernetes deployment.
  • Secret Mission: Add a health check endpoint and Docker HEALTHCHECK instruction for production readiness.

Are there any prerequisites?

This project assumes you have Node.js, Docker Desktop, and the AWS CLI installed and configured. You also need an AWS account with billing enabled.

Familiarity with Next.js basics, Docker fundamentals, and running AWS CLI commands will help you move through this project smoothly.

Create the Next.js App and Configure Standalone Output

When you containerize a Next.js app, the biggest challenge is image size. A standard Next.js project ships with hundreds of megabytes of node_modules that the production server never touches.

Next.js solves this with a feature called standalone output tracing. It analyzes your code at build time and copies only the files the server actually needs into a self-contained folder. In this step, you will scaffold the app, enable standalone output, and create an API route that proves runtime environment variables work correctly inside a container.

In this step, get ready to:
  • Scaffold a Next.js app and enable standalone output tracing.
  • Create an API route and page that demonstrate runtime environment variables.
  • Verify the app runs locally before containerizing.
Scaffold the Next.js project

The create-next-app CLI scaffolds a full Next.js project with TypeScript, the App Router, and all dependencies pre-installed.

  • Move to your Desktop by running this command:
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Next.js project by running this command:
npx create-next-app@latest nextjs-docker-demo --yes

What does this command do?

The --yes flag accepts all the recommended defaults without prompting you. This gives you TypeScript, ESLint, Tailwind CSS, and the App Router out of the box.

  • Move into your new project folder by running:
cd nextjs-docker-demo
  • Confirm the project files exist by running:
ls

You should see files like package.json, next.config.ts, tsconfig.json, and an app/ folder in the output.

Command not found?

If you see an error about npx not being found, make sure Node.js is installed and your terminal session is fresh. Try closing and reopening your terminal, then run node --version to confirm Node.js is available.

help me fix a 'command not found' error when running npx create-next-app

Configure standalone output in next.config.ts

By default, a Next.js production build expects the entire node_modules directory at runtime. Enabling standalone output tells Next.js to trace dependencies and produce a self-contained server that runs without node_modules.

  • Open next.config.ts in the root of your nextjs-docker-demo folder.
  • Replace the entire file contents with this code:
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Enables output file tracing for minimal Docker images
  output: "standalone",
};

export default nextConfig;

What does this code do?

  • output: "standalone" tells Next.js to analyze your imports at build time and copy only the required files into a .next/standalone folder.
  • The standalone folder contains a minimal server.js that can serve your entire app without node_modules.
  • Save next.config.ts.

Don't see next.config.ts?

Make sure you are looking in the root of the nextjs-docker-demo folder, not inside the app/ subfolder. The config file lives at the top level alongside package.json.

help me find next.config.ts in my Next.js project

Create the API route and update the page

You need a way to prove that environment variables work at container runtime, not just at build time. An API route that reads process.env.APP_ENV is the perfect test. If the container receives the variable at startup, the API returns it in the response.

  • Create the folder for the API route by running:
mkdir -p app/api/hello
  • Create a new file at app/api/hello/route.ts and paste in this code:
import { NextResponse } from "next/server";

export async function GET() {
  // Read APP_ENV at runtime - defaults to "development" if not set
  const environment = process.env.APP_ENV || "development";

  return NextResponse.json({
    message: `Hello from ${environment}`,
    timestamp: new Date().toISOString(),
  });
}

What does this code do?

  • The GET() function handles HTTP GET requests to /api/hello.
  • process.env.APP_ENV reads the APP_ENV environment variable at runtime. This is the key detail. Because APP_ENV is not prefixed with NEXT_PUBLIC_, Next.js reads it fresh on every request instead of baking it in at build time.
  • NextResponse.json() sends the response as JSON with the correct headers.
  • Save app/api/hello/route.ts.

Now update the homepage to call this API and display the result.

  • Open app/page.tsx and replace the entire file contents with this code:
export default async function Home() {
  // Use NEXT_PUBLIC_BASE_URL for the fetch target, defaulting to localhost
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

  let greeting = "Loading...";
  try {
    // Fetch from our API route with no caching to always get fresh data
    const res = await fetch(`${baseUrl}/api/hello`, { cache: "no-store" });
    const data = await res.json();
    greeting = data.message;
  } catch {
    greeting = "Failed to fetch greeting";
  }

  return (
    <main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
      <h1>Next.js Production Docker Demo</h1>
      <p>
        This page is server-rendered inside a Docker container. The greeting
        below comes from an API route that reads a runtime environment variable.
      </p>
      <div
        style={{
          marginTop: "1rem",
          padding: "1rem",
          background: "#f0f0f0",
          borderRadius: "8px",
        }}
      >
        <strong>API Response:</strong> {greeting}
      </div>
    </main>
  );
}

What does this code do?

  • This is a server component (the default in the App Router). It runs on the server, fetches data, and sends the finished HTML to the browser.
  • cache: "no-store" ensures the fetch always hits the API route fresh, so you see the current environment variable value.
  • The try/catch block handles cases where the API might not be reachable, displaying a fallback message instead of crashing.
  • Save app/page.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/api/hello/route.ts and app/page.tsx.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;
import { NextResponse } from "next/server";

export async function GET() {
  const environment = process.env.APP_ENV || "development";

  return NextResponse.json({
    message: `Hello from ${environment}`,
    timestamp: new Date().toISOString(),
  });
}
export default async function Home() {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

  let greeting = "Loading...";
  try {
    const res = await fetch(`${baseUrl}/api/hello`, { cache: "no-store" });
    const data = await res.json();
    greeting = data.message;
  } catch {
    greeting = "Failed to fetch greeting";
  }

  return (
    <main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
      <h1>Next.js Production Docker Demo</h1>
      <p>
        This page is server-rendered inside a Docker container. The greeting
        below comes from an API route that reads a runtime environment variable.
      </p>
      <div
        style={{
          marginTop: "1rem",
          padding: "1rem",
          background: "#f0f0f0",
          borderRadius: "8px",
        }}
      >
        <strong>API Response:</strong> {greeting}
      </div>
    </main>
  );
}
Run the app locally and verify

Before containerizing anything, confirm the app works on its own. This catches typos early, before Docker adds another layer of complexity.

  • Start the development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.

You should see the heading "Next.js Production Docker Demo" and an API response showing "Hello from development".

  • Open a new browser tab and navigate to http://localhost:3000/api/hello.

You should see a JSON response like {"message":"Hello from development","timestamp":"..."}.

Why does it say "development"?

You did not set the APP_ENV environment variable, so the code falls back to the default value "development". When you run the Docker container later, you will pass -e APP_ENV=docker and the response will change to "Hello from docker".

Page not loading?

Make sure npm run dev is still running in your terminal. Look for the line that says the server is ready on localhost:3000. If you see an error about port 3000 already in use, another process is occupying that port. Stop it or try a different port with npm run dev -- -p 3001.

help me debug why my Next.js dev server is not loading in the browser

  • Stop the dev server by pressing Ctrl+C in your terminal. You are done verifying.

Why standalone output matters for Docker

Without standalone mode, a Docker image must include the entire node_modules directory at runtime. That can push your image past 1 GB.

With standalone output tracing, Next.js copies only the files needed by the production server into .next/standalone. The result is a production image that is 5 to 7 times smaller.

Your app is running and reading environment variables at runtime. Next up, you will write the production Dockerfile that turns this into a lean, secure container image.

Write the Production Multi-Stage Dockerfile

Your Next.js app is running locally and configured with standalone output tracing. Now it is time to package it into a production-ready Docker image.

A single-stage Dockerfile would copy everything (including the full node_modules directory) into one image. A multi-stage Dockerfile separates concerns into isolated stages so that only the files needed at runtime end up in the final image. This keeps the production image small, secure, and fast to deploy.

In this step, get ready to:
  • Create a .dockerignore file to keep unnecessary files out of the build context.
  • Write a 3-stage production Dockerfile (dependencies, builder, runner).
  • Understand why each instruction exists and how it optimizes the image.
Create the .dockerignore and Dockerfile

Before Docker builds an image, it sends your project directory (the "build context") to the Docker daemon. Files that have no business being inside the image waste time and space during this transfer.

  • In your code editor, create a new file called .dockerignore in the root of your project folder (the same level as package.json). Add the following content:
node_modules
.next
.git
.gitignore
*.md
.env*.local
docker-compose*.yml
.dockerignore
Dockerfile

What does this file do?

The .dockerignore file tells Docker which files to exclude from the build context. node_modules and .next are excluded because the Dockerfile installs dependencies and builds fresh inside the image. .git and markdown files add bulk without contributing to the runtime.

  • Save .dockerignore.

Now you will write the Dockerfile one stage at a time. Each stage has a specific job: install dependencies, build the app, or run the server.

  • Create a new file called Dockerfile (no extension) in the root of your project folder. Add the first stage:
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:22-slim AS dependencies

WORKDIR /app

COPY package.json package-lock.json* ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit --no-fund

What does this stage do?

This stage focuses solely on installing packages. COPY package.json package-lock.json* ./ copies only the package manifest files first. Docker caches this layer, so dependencies only reinstall when those files change.

The --mount=type=cache,target=/root/.npm flag creates a persistent cache for npm's downloaded packages across builds. Combined with npm ci, this produces a clean, reproducible install from the lockfile every time.

You won't see a change yet. The next chunk adds Stage 2.

  • Below Stage 1 in your Dockerfile, add the build stage:
# ============================================
# Stage 2: Build the Next.js application
# ============================================
FROM node:22-slim AS builder

WORKDIR /app

COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production

RUN npm run build

What does this stage do?

The builder stage copies the installed node_modules from Stage 1, then copies in all source files. Setting NODE_ENV=production tells Next.js to produce an optimized production build.

Running npm run build triggers Next.js output tracing. Because you configured output: "standalone" in next.config.ts, the build generates a .next/standalone folder containing only the files needed at runtime.

You won't see a change yet. The next chunk completes the Dockerfile with Stage 3.

  • Below Stage 2, add the final production runner stage:
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:22-slim AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

COPY --from=builder --chown=node:node /app/public ./public

RUN mkdir .next && chown node:node .next

COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node

EXPOSE 3000

CMD ["node", "server.js"]

What does this stage do?

The runner stage assembles the minimal production image. It copies .next/standalone (the traced server), public/ (static assets), and .next/static (compiled CSS/JS) from the builder.

Setting HOSTNAME=0.0.0.0 binds the server to all network interfaces inside the container. Without it, the app would only listen on localhost and be unreachable from outside.

The USER node instruction switches to a non-root user for security. The --chown=node:node flags ensure all copied files are owned by this user.

  • Save Dockerfile.
  • Confirm both new files exist by running:
ls Dockerfile .dockerignore

You should see both Dockerfile and .dockerignore listed in the output.

Don't see both files?

  • Make sure Dockerfile has no file extension. Some editors add .txt automatically.
  • The .dockerignore file starts with a dot. On macOS, dotfiles are hidden by default. Use ls -a to reveal hidden files.
  • Both files must be at the same level as package.json. If you created them inside a subfolder, move them to the project root.

Still stuck? Help me find my Dockerfile and .dockerignore files

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete .dockerignore:

node_modules
.next
.git
.gitignore
*.md
.env*.local
docker-compose*.yml
.dockerignore
Dockerfile

Here is the complete Dockerfile:

# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:22-slim AS dependencies

WORKDIR /app

COPY package.json package-lock.json* ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit --no-fund

# ============================================
# Stage 2: Build the Next.js application
# ============================================
FROM node:22-slim AS builder

WORKDIR /app

COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production

RUN npm run build

# ============================================
# Stage 3: Production runner
# ============================================
FROM node:22-slim AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

COPY --from=builder --chown=node:node /app/public ./public

RUN mkdir .next && chown node:node .next

COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node

EXPOSE 3000

CMD ["node", "server.js"]
Understand the runner stage in detail

The runner stage is where most of the production optimization happens. Let's break down why it copies three separate things from the builder.

Why copy public/ and .next/static separately?

Next.js standalone output tracing deliberately excludes public/ and .next/static/ from the traced output. The official docs state: "This minimal server does not copy the public or .next/static folders by default as these should ideally be handled by a CDN instead."

When you self-host without a CDN (like running in a Docker container), you must copy these folders into the image manually. Without public/, your favicon and static assets are missing. Without .next/static/, your compiled JavaScript and CSS bundles are gone.

Why is HOSTNAME=0.0.0.0 required?

Starting in Next.js 15+, the standalone server defaults to listening on localhost (loopback only). Inside a container, localhost means the container's own internal loopback. Traffic from outside the container (including your docker run -p 3000:3000 port mapping) cannot reach it.

Setting HOSTNAME=0.0.0.0 binds the server to all network interfaces, making it reachable from outside the container. This is the most common cause of "container runs but app is unreachable" issues.

Why switch to a non-root user?

By default, containers run as root. If an attacker exploits a vulnerability in your app, they gain root access inside the container. The USER node instruction switches to the built-in node user (included in all official Node images), limiting potential damage.

The --chown=node:node flag on each COPY ensures the files are owned by this non-root user so the server can read them.

Examine why each instruction exists

Every line in this Dockerfile serves a purpose. Understanding the design decisions helps you adapt this pattern to any Next.js project.

Why use a cache mount instead of just Docker layer caching?

Docker's built-in layer caching works when the COPY package.json layer is unchanged. But if you update even one dependency, the entire npm ci layer rebuilds from scratch and re-downloads everything.

The --mount=type=cache,target=/root/.npm instruction creates a persistent cache directory that survives across builds. Even when the layer is invalidated, npm can reuse previously downloaded packages from this cache. This means only new or changed packages get downloaded.

Why copy package.json before the full source?

Docker invalidates a layer (and all layers after it) whenever the files it depends on change. By copying package.json and package-lock.json in a separate COPY before COPY . ., the dependency install layer is cached as long as those files are unchanged.

If you edit only your source code (not your dependencies), Docker skips the entire npm ci step on rebuild. This saves significant time during development iteration.

Why use node:22-slim instead of Alpine?

Alpine Linux uses musl instead of glibc. Some native Node modules (like sharp for image optimization or Prisma) can fail silently or require extra configuration on Alpine.

The slim variant is Debian-based with glibc, giving you broader compatibility. The tradeoff is roughly 50MB of extra size compared to Alpine. For a production deployment (especially one heading to Kubernetes), compatibility wins over a small size difference.

Your Dockerfile is ready. Next up, you will build the image and test it locally to confirm everything works inside the container.

Build the Image and Test Locally

In the previous step, you wrote a multi-stage Dockerfile that separates dependencies, building, and running into isolated stages. Now it's time to put it to work.

Building the image runs all three stages in sequence. Docker processes your instructions, produces a single production-ready image, and you'll run a container from that image to confirm everything works inside the containerized environment.

In this step, get ready to:
  • Build the production Docker image and confirm its size is under 350MB.
  • Run the container locally and verify SSR works at localhost:3000.
  • Confirm the API route reads runtime environment variables correctly.
Build the production image

Your Dockerfile describes a three-stage build process. Running docker build kicks off all three stages and produces a single tagged image you can run anywhere Docker is installed.

  • Build the production Docker image by running this command:
docker build -t nextjs-production .

What happens during this build?

Docker processes your three named stages in order. The terminal output shows each stage as it runs.

  • The dependencies stage pulls the node:22-slim base image, copies your package.json, and runs npm ci with a cache mount so packages are stored for future rebuilds.
  • The builder stage copies the installed node_modules from the previous stage, copies your source code, and runs next build to produce the standalone output.
  • The runner stage assembles the final minimal image by copying only .next/standalone, public/, and .next/static from the builder.

The build takes 1-3 minutes on the first run while Docker pulls the base image and installs dependencies.

  • Once the build finishes, check the final image size by running:
docker images nextjs-production

You should see a table with columns for REPOSITORY, TAG, IMAGE ID, CREATED, and SIZE. The SIZE column shows your image at roughly 200-350MB.

Cache mounts speed up rebuilds

Try running docker build -t nextjs-production . again. The second build completes in seconds because Docker reuses cached layers for the dependency install and only rebuilds stages where files changed.

Image larger than 500MB?

If your image is over 500MB, the standalone copy in the runner stage is likely wrong. The COPY --from=builder /app/.next/standalone ./ line must copy the standalone directory, not the full node_modules. Double check your Dockerfile matches the runner stage from the previous step.

help me debug why my Docker image is over 500MB for a Next.js standalone build

Run the container and verify the app

A built image is just a blueprint. You need to run a container from it to confirm the app actually works. You'll pass a runtime environment variable to prove the container reads it at startup, not at build time.

  • Start a container from your image by running this command:
docker run -p 3000:3000 -e APP_ENV=docker nextjs-production

What do these flags do?

  • -p 3000:3000 maps port 3000 inside the container to port 3000 on your machine. This lets you visit the app in your browser.
  • -e APP_ENV=docker sets a runtime environment variable inside the container. Your API route reads this value and returns it in the response.
  • nextjs-production is the image tag you assigned during the build.
  • Open your browser and visit http://localhost:3000.
  • You should see the page titled "Next.js Production Docker Demo" with an API Response section showing a greeting.
  • Visit http://localhost:3000/api/hello directly in your browser.
  • You should see a JSON response like {"message":"Hello from docker","timestamp":"..."}. The word "docker" confirms the container read the runtime APP_ENV variable you passed with -e.

Why does this prove runtime env vars work?

The APP_ENV variable was never set during the build. It was passed only at container start time with the -e flag. Seeing "Hello from docker" in the response proves the container reads this value at runtime, not at build time.

This distinction is critical for production. The same image can run in staging, production, or local environments just by changing the environment variables at runtime.

  • Return to the terminal where the container is running.
  • Press Ctrl+C to stop the container.

Container runs but app is unreachable?

If you see the container start but cannot reach localhost:3000 in your browser, check that your Dockerfile includes ENV HOSTNAME=0.0.0.0 in the runner stage. Without this, the Next.js server binds to localhost inside the container, making it unreachable from your host machine.

If styles or static assets are missing, confirm the COPY --from=builder --chown=node:node /app/.next/static ./.next/static line exists in your runner stage. The standalone output does not include static files by default.

help me troubleshoot my Next.js Docker container that starts but is unreachable at localhost:3000

Your production image is built, tested, and confirmed working. Next up, you'll push this image to Amazon ECR so it's available for deployment on any AWS service.

Push the Production Image to Amazon ECR

Your production image is built, tested, and optimized. The next goal is storing it somewhere a container orchestrator can pull it from on any machine in your infrastructure.

Amazon ECR (Elastic Container Registry) is a fully managed private Docker registry hosted by AWS. In this step, you'll create a repository in ECR, push your image to it, and confirm it works by pulling it back down.

In this step, get ready to:
  • Create an ECR private repository for your production image.
  • Authenticate Docker to ECR, tag, and push the image.
  • Pull the image from ECR and run it locally to verify everything works.
Create the ECR private repository

Before you can push an image, you need a repository to hold it. Each ECR repository stores images for a single application or service.

  • Create the repository by running this command:
aws ecr create-repository --repository-name nextjs-production --region us-east-1

What does this command do?

This creates a private repository called nextjs-production in the us-east-1 region. The output includes a repositoryUri field that you'll use for tagging and pushing your image.

The URI format is <account-id>.dkr.ecr.us-east-1.amazonaws.com/nextjs-production. Copy this value from your terminal output. You'll need it in the next few commands.

  • Find the repositoryUri in the JSON output and copy it somewhere you can reference later (a text file or clipboard).

How much does ECR cost?

ECR storage costs $0.10 per GB per month. Your ~250MB image costs about $0.025/month. Data transfer into ECR is free, and pulling from ECR within the same region (like from EKS) is also free.

New ECR customers get 500 MB of free storage per month for the first year.

Getting an access denied error?

Make sure your AWS CLI credentials have permissions for ECR actions. Your IAM user or role needs ecr:CreateRepository permissions at minimum. If you used aws configure earlier, verify the credentials are still valid.

Help me fix my ECR permissions error.

Authenticate Docker, tag, and push the image

Docker needs to authenticate with ECR before it can push images to your private registry. The authentication token is valid for 12 hours.

  • Authenticate Docker to your ECR registry by running this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com

What does this command do?

This is two commands piped together. The first command (aws ecr get-login-password) fetches a temporary authentication token from AWS. The pipe sends that token to docker login, which stores it so Docker can push to your registry.

Replace the account ID with your 12-digit AWS account ID. You can find this in the repositoryUri you copied earlier (it's the number before .dkr.ecr).

  • You should see Login Succeeded in your terminal output.

Login failed or token error?

Double-check that your AWS account ID is correct (12 digits, no dashes). Also confirm Docker Desktop is running. If your AWS session has expired, run aws sts get-caller-identity to check your credentials are valid.

Help me troubleshoot my ECR login.

  • Tag your local image with the full ECR repository URI by running this command:
docker tag nextjs-production:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/nextjs-production:latest

Why do I need to tag the image?

Docker uses the image tag to determine which registry to push to. By tagging your local nextjs-production image with the full ECR URI, you're telling Docker where this image should live remotely.

  • Push the tagged image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/nextjs-production:latest

The push uploads each layer of your image individually. You'll see progress bars for each layer as it uploads.

  • Verify the push succeeded by listing images in the repository:
aws ecr describe-images --repository-name nextjs-production --region us-east-1
  • You should see JSON output with an imageDetails array containing your image's imageDigest, imageSizeInBytes, and imageTags (showing latest).

Push seems stuck or timed out?

Large images can take a few minutes to upload depending on your internet speed. If the push fails partway through, run the docker push command again. Docker only re-uploads layers that haven't been received yet.

Help me fix my ECR push failure.

Pull from ECR and verify the image works

The final test simulates what a container orchestrator like Kubernetes does when it deploys your app. It pulls the image from ECR and runs a container from it.

  • Pull the image from ECR by running:
docker pull [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/nextjs-production:latest
  • Run the pulled image with a different environment variable to confirm runtime env vars still work:
docker run -p 3000:3000 -e APP_ENV=ecr [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/nextjs-production:latest

What does this prove?

By passing APP_ENV=ecr instead of APP_ENV=docker, you can confirm the image you pulled from ECR behaves identically to the one you tested locally. The API response should now say "Hello from ecr" instead of "Hello from docker".

  • Open your browser and visit http://localhost:3000. Confirm the page renders with the server-side greeting.
  • Visit http://localhost:3000/api/hello and confirm the response includes "message":"Hello from ecr".
  • Stop the container by pressing Ctrl+C in the terminal.

App unreachable at localhost:3000?

Make sure you don't have another container or process already using port 3000 from your earlier testing. Run docker ps to check for running containers. Stop any existing ones with docker stop <container-id> before retrying.

Help me debug why the ECR container isn't responding.

Your production image is now stored in a private registry that any AWS service can pull from. In the next project, you'll deploy this exact image to a Kubernetes cluster on Amazon EKS.

Secret mission

Add a Health Check Endpoint and Docker HEALTHCHECK

Production containers need a way to tell orchestrators whether they're alive and ready to serve traffic. In this secret mission, you'll add a dedicated health endpoint and a Docker HEALTHCHECK instruction so your container can self-report its status.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ECR repository stores your image at approximately $0.025/month (well within the 500MB free tier for new ECR customers in the first year).

Cost warning

Your ECR private repository stores one image (~250MB) at $0.10 per GB per month. This costs approximately $0.025/month. If you are a new ECR customer within your first year, the 500MB free tier covers this entirely.

If you plan to continue to Project 2 (EKS deployment), keep the ECR repository in place. Otherwise, delete it to avoid any charges.

Resources you used:

  • Local Docker image nextjs-production (rebuilt with HEALTHCHECK).
  • Local containers (stopped after testing).
  • Docker build cache from multi-stage builds.
  • ECR private repository nextjs-production in us-east-1 with one pushed image (~250MB).
  • Local Next.js project directory on your Desktop.

Keep everything running

No action needed. Choose this if you plan to continue to Project 2 (deploying to EKS), where you will pull this image from ECR into your Kubernetes cluster.

  • Your ECR repository stays in place at ~$0.025/month (covered by the free tier for new customers).
  • Your local Docker image and build cache remain available for fast rebuilds.
  • Your local project directory stays on your Desktop for further development.

Pause - I'll come back to this later

Free up local Docker resources while keeping your ECR image and project files intact.

  • Stop any running containers by running:
docker ps -q --filter ancestor=nextjs-production | xargs -r docker stop
  • Prune your Docker build cache to free disk space by running:
docker builder prune -f

Your ECR repository and local project directory remain untouched. You can rebuild the image anytime from your project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Delete the ECR repository and all images inside it by running:
aws ecr delete-repository --repository-name nextjs-production --region us-east-1 --force
  • Remove the local Docker image by running:
docker rmi nextjs-production
  • Prune the Docker build cache by running:
docker builder prune -f
  • Remove the local project directory. Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf nextjs-docker

On Windows, use this command instead to remove the project folder:

cd ~\Desktop
rmdir /s /q nextjs-docker

All resources are now removed. Your AWS account no longer has the ECR repository, and your local machine is clean.

That's a Wrap!

That's a Wrap!

Nice work! You've containerized a Next.js application for production and pushed it to Amazon ECR, ready for Kubernetes deployment.

You've learned how to:

  • Write a 3-stage production Dockerfile following the official Vercel pattern to separate dependency installation, building, and runtime into isolated stages.
  • Build and test a production-optimized Docker image (~250MB) running as a non-root user with proper environment variable handling and all-interface binding.
  • Push the production image to an Amazon ECR private repository, authenticating Docker and verifying the image is pullable for orchestrator deployments.
  • Secret Mission: Added a /api/health endpoint and a Docker HEALTHCHECK instruction for container orchestration readiness.

Ready to quiz yourself?