Lesson 9 · 20 min read

Docker: Containerize Your Next.js and Node App

Images, layers, Dockerfiles and Compose — build once, run the same thing on your laptop and on EC2.

What you'll learn in this lesson

  1. Concept: ship the whole box, not just the code
  2. Why this matters
  3. Image, container, registry — the three words
  4. Container vs virtual machine
  5. Install Docker
  6. Your first containers
  7. Layers and the build cache
  8. A production Dockerfile for Next.js
  9. Build it and run it
  10. Config, secrets, ports and data
  11. Docker Compose — the whole stack in one file
  12. Run it on EC2
  13. Registries and ECR
  14. Debugging containers
  15. Ten mistakes to skip
  16. Interview corner
  17. Practice task before Lesson 10
  18. Conclusion

Concept

Docker is a tool that packs your application and everything it needs into one package called an image. That includes the exact Node version, the libraries, the system tools and the start command. Any computer with Docker can run the image and get exactly the same behaviour. This works on your laptop, your teammate's Windows PC, a CI server (a machine that builds and tests your code automatically) and an EC2 instance.

The analogy — the shipping container

Before standard shipping containers, every cargo was loaded in a different way for every ship and port. Then the industry agreed on one standard box. Any crane, ship or truck can move it without knowing what is inside. Docker is that box for software. The server does not care whether the app inside is Node, Python or Java. It just runs the box.

Why this matters

In Lesson 7 you set up a server by hand: install Node, clone, build, PM2. It worked. But look at what you made: a server that only you know how to rebuild.

  • “Works on my machine.” Your laptop has Node 22.3 and the server has 22.11, so a native module (code written in C or C++ that Node loads) behaves differently. Docker removes the difference: the same image runs everywhere.
  • Slow setup with many mistakes. A new server means repeating 20 manual steps. With Docker you write the steps once, in the Dockerfile.
  • Auto Scaling needs it. Lesson 12 creates servers automatically when traffic grows. A new server must be ready with no human steps. “Install Docker, pull the image, run it” is only three commands.
  • Easy rollback. Every release is an image with a version. To roll back (go back to the old version), you run the previous tag.
  • Build once, ship the result. The build happens in CI, not on the production server that is busy serving customers.
  1. Your laptop
    git push origin main
  2. GitHub receives the commit
    GitHub Actions (CI)
    installs, lints, runs tests, npm run build
  3. any step fails → stop here, nothing deployed
    Docker build
    packages app + Node + deps into one image, pushed to a registry
  4. same image runs on your laptop and on EC2
    Deploy (CD)
    EC2 pulls the image · or Terraform/ASG rolls new servers
  5. health check passes → old version retired
    PM2 / container restarted
    zero-downtime reload
  6. Nginx keeps serving throughout
    Users
    see the new version — minutes after the push, no SSH
From git push to live: the automated pipeline Lessons 9, 14 and 15 build. Lesson 7 does the same thing by hand first, so you understand every step the automation later performs.

Image, container, registry — the three words

WordWhat it isThink of it as
ImageA read-only package that holds your code, Node, libraries and settings. You build it once and it never changes.A recipe card plus all the ingredients, sealed in a box
ContainerA running copy of an image. Start ten containers from one image and you get ten identical running programs.A dish cooked from that box
RegistryA server that stores images, so that other machines can download them.The warehouse: Docker Hub, GitHub Container Registry, AWS ECR
DockerfileA text file with the steps to build an image.The recipe
VolumeA folder that lives outside the container, so the data stays even if the container is deleted.A fridge that stays when the dish is thrown away

Remember this chain: Dockerfile → (build) → Image → (run) → Container. In one line: an image is the frozen package, and a container is that package running. Images move between machines through a registry. Build once, push the image to the registry, then pull it on any machine.

A tag is a label on an image, such as myapp:1.0.0. The part before the colon is the name, and the part after it is the version.

Container vs virtual machine

A virtual machine (VM) is a whole pretend computer, with its own operating system, running on real hardware. Your EC2 instance is a virtual machine. A container is lighter. It shares the host's operating-system kernel and only separates a process and its files from the others.

ContainerVirtual machine (EC2)
ContainsYour app and its librariesA whole operating system and your app
SizeTens to hundreds of MBGigabytes
Starts inUnder a secondTens of seconds to minutes
IsolationShares the host’s kernel (the core of the operating system). Walls are at process levelHas its own kernel. Walls are at hardware level
DensityDozens per machineA few per machine
An EC2 instance is—A virtual machine. Containers run inside it

They work together, not against each other: containers run on top of EC2. AWS also has services that run containers for you (ECS, EKS and App Runner; see Lesson 19). Writing a good image is the same skill for all of them.

Install Docker

On your laptop

Install Docker Desktop (Mac, Windows) or Docker Engine (Linux). Docker Engine is the background program that builds and runs containers. On Windows, use the WSL 2 backend (Linux inside Windows) from Lesson 1. Then check that it works:

Laptop
  • docker run hello-world

    Downloads a tiny image and runs it. The message “Hello from Docker!” means that everything works

On the Ubuntu EC2 server

Follow Docker's own “Install Docker Engine on Ubuntu” page. It adds Docker's signed package source, then installs the engine and the Compose plugin. Afterwards, add the ubuntu user to the docker group, so that you do not need sudo each time. Then log out and log in again.

Membership of the docker group equals root

Anyone who can run docker can attach the host's files to a container and become root. Add only trusted admins to that group, as you would for sudo (Lesson 1).

Your first containers

Your first container
  • docker run -d --name web -p 8080:80 nginx

    Download the official nginx image (nginx is a web server), start it in the background (-d), and connect laptop port 8080 to container port 80. Visit http://localhost:8080

The other commands are easy to guess. You can list running containers (docker ps), follow a container's logs (docker logs, where the app's output goes), open a shell inside it (docker exec -it), stop it (docker stop) and remove it (docker rm). Removing a container keeps its image on disk for next time.

Port mapping confuses everyone at first. -p 8080:80 means host port : container port. The container has its own private network. Without -p, nothing outside can reach it. The number on the left is the door on your machine that a visitor uses.

Layers and the build cache

An image is a stack of layers. A layer is one saved step of the build. Instructions that change files (RUN, COPY, ADD) each make a layer. Docker keeps each layer in a cache (a store of earlier results). It reuses a layer if its instruction and everything before it did not change. This one fact explains most Dockerfile advice.

The order of instructions decides your build time
# SLOW: any code change makes the COPY layer new, so npm install runs again every time
COPY . .
RUN npm install

# FAST: dependencies change rarely and code changes often, so put the rarely-changing step first
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile     # cached until the lockfile changes
COPY . .                               # only this layer rebuilds on a code change

Rule: put what changes least at the top and what changes most at the bottom. A rebuild that uses the cache takes seconds. One without the cache takes minutes.

A production Dockerfile for Next.js

First turn on Next.js's standalone output. It finds exactly which files the server needs and copies only those. This can shrink the app files a lot, so the image is often a few hundred MB instead of over 1 GB:

next.config.ts
import type { NextConfig } from "next";

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

export default nextConfig;

Now the Dockerfile. It is a multi-stage build: one Dockerfile with several steps (stages), where only the last stage becomes the final image. Read it from top to bottom:

Dockerfile
# ---------- Stage 1: install dependencies ----------
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

# ---------- Stage 2: build the app ----------
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build

# ---------- Stage 3: the small image we actually ship ----------
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
    PORT=3000 \
    HOSTNAME=0.0.0.0

# A normal user, not root
RUN addgroup -S app && adduser -S app -G app

# Copy ONLY the output. No source, no dev dependencies, no build tools.
COPY --from=build --chown=app:app /app/.next/standalone ./
COPY --from=build --chown=app:app /app/.next/static ./.next/static
COPY --from=build --chown=app:app /app/public ./public

USER app
EXPOSE 3000
CMD ["node", "server.js"]

Reading it line by line

InstructionWhy it is there
FROM node:22-alpine AS depsStart from an official image with Node 22 on Alpine Linux (a very small Linux; the whole image is roughly 150 MB on disk). AS gives the stage a name, so later stages can copy from it. Use the same major Node version as on your laptop (Node 24 also works if that is what you use everywhere).
COPY package.json pnpm-lock.yaml then RUN pnpm install --frozen-lockfileThis is the cache trick from above. --frozen-lockfile stops with an error instead of quietly changing package versions.
Three FROMs (multi-stage)Every stage is thrown away except the last one. Compilers, dev dependencies and source code never reach the shipped image, so it is smaller and safer.
ENV HOSTNAME=0.0.0.0Makes the server listen on all network interfaces. Without it, the standalone server may listen only on the container's own loopback address (127.0.0.1, which only the container itself can reach), so nothing outside can connect.
adduser + USER appThe process runs as a normal user, not as root. If someone breaks into the app, they are not root in the container.
EXPOSE 3000A note for readers only. It does not open the port. Only -p does that.
CMD ["node", "server.js"]The command that runs when a container starts. The array form runs Node directly, without a shell in between. Node becomes PID 1 (the first process in the container), so it receives stop signals from Docker.

The .dockerignore file — do not skip it

COPY . . copies everything in the folder. The .dockerignore file tells Docker what to leave out. Without it, your .env secrets, your .git history and a 1 GB local node_modules go into the build.

.dockerignore
node_modules
.next
.git
.env*
*.pem

Build it and run it

On your laptop
  • docker build -t myapp:dev .

    Build an image named myapp with the tag dev, from the Dockerfile in this folder. The first build is slow. A second build that uses the cache takes seconds. Expect a few hundred MB

  • docker run --rm -p 3000:3000 --env-file .env.local myapp:dev

    Run it and load the variables when it starts. --rm deletes the container when it stops. Visit http://localhost:3000

Build-time vs run-time variables

NEXT_PUBLIC_* variables are copied into the JavaScript during pnpm build. So inside Docker you must give them at build time (ARG NEXT_PUBLIC_API_URL, then docker build --build-arg …). Everything else, such as the database URL and secrets, is read at run time and must never be in the image.

Config, secrets, ports and data

NeedDo thisNot this
Database URL, API keys--env-file or -e at run time (or fetched from SSM/Secrets Manager — Lesson 18)COPY .env or ENV SECRET=… in the Dockerfile
AWS permissionsThe instance's IAM role. Containers use it automatically (see the hop limit note below)Access keys inside the image
User uploads, filesS3 (Lesson 11)A folder inside the container
Database filesRDS (Lesson 8), or a named volume for local devThe container's own filesystem
LogsWrite to stdout and stderr (the normal output streams); Docker and CloudWatch collect themLog files inside the container
Anything in an image layer is public to whoever can pull it

Deleting a secret in a later Dockerfile line does not remove it. It still exists in the earlier layer. If a secret ever got into an image, replace the secret with a new one. Do not just rebuild.

Docker Compose — the whole stack in one file

Real apps use several containers: the app, a database, and later Redis (a fast in-memory data store). Docker Compose is a tool that describes all of them in one file and starts them with one command. It is the standard tool for local development and for simple single-server setups.

docker-compose.yml — local development
services:
  app:
    build: .
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgresql://appuser:devpass@db:5432/myapp   # host is the SERVICE NAME "db"
    depends_on: [db]

  db:
    image: postgres:16
    environment: { POSTGRES_USER: appuser, POSTGRES_PASSWORD: devpass, POSTGRES_DB: myapp }
    volumes: [pgdata:/var/lib/postgresql/data]   # data survives `docker compose down`

volumes:
  pgdata:

docker compose up -d builds and starts everything in the background. docker compose down stops and removes the containers, but it keeps the data volume. Add -v to delete the data too.

Why “db” works as a hostname

Compose creates a private network for the project. Each service gets a DNS name (a name that points to an address) equal to its service name. From app, the name db points to the database container. This is also why localhost would be wrong. Inside the app container, localhost means app itself.

In production, do not put the database in Compose. Use RDS (Lesson 8) and set DATABASE_URL to its endpoint. The dev compose file above is close enough to production to catch mistakes, and it costs nothing.

Run it on EC2

Here the twenty manual steps of Lesson 7 shrink to a few. Once Docker is installed on the server, deploying means pull and run. (This time we copy the image to the server by hand. The next sections use a registry, which is the proper way.)

Put the production variables in ~/myapp.env on the server. Set its permissions to 600, and never put it in Git or in the image. Then run:

on the server
docker run -d --name myapp \
  --restart unless-stopped \
  --env-file ~/myapp.env \
  -p 127.0.0.1:3000:3000 \
  --memory 1g \
  --log-opt max-size=10m --log-opt max-file=3 \
  myapp:1.0.0

Each flag earns its place:

  • --restart unless-stopped replaces PM2. Docker starts the container again after a crash and after a reboot.
  • -p 127.0.0.1:3000:3000 opens the port only on the server's loopback address (127.0.0.1, which only the server itself can reach). Nginx (Lesson 10) can reach it, and the internet cannot. Plain -p 3000:3000 listens on every interface. Docker also changes the firewall rules by itself, so the port can be open to the world even if you thought a firewall rule blocked it.
  • --memory 1g limits memory. If the app leaks memory, only one container restarts and the whole server does not run out of memory.
  • --log-opt max-size stops the JSON log files from quietly filling the disk. This is a common cause of outages.
If the container cannot find AWS credentials

The instance metadata service (Lesson 7) only answers requests that travel a limited number of network hops (steps between devices). A container is one hop further away than the host. On many instances the hop limit is 1, so the AWS SDK inside the container fails with “could not load credentials”, even though the host works. (Some images, such as Amazon Linux 2023, already use 2.) Set it to 2 once, and keep IMDSv2 required: EC2 → your instance → Actions → Instance settings → Modify instance metadata options.

Registries and ECR

“Copy the image to the server” does not work for many servers. A registry is the standard hand-over point. CI pushes an image, and every server pulls it. AWS's own registry is ECR (Elastic Container Registry). It is private by default, works with IAM, and has no data transfer charge when you pull from inside the same region.

The flow has three steps. You can do them from your laptop or (later) from CI:

  1. Create a private repository in ECR named myapp. Turn on scan on push, so every image is checked for known vulnerabilities (security holes that are already public).
  2. Log Docker in to ECR with a temporary token from the AWS CLI. The token lasts 12 hours.
  3. Build the image, tag it with the Git commit (myapp:a1b2c3d), and push it.

You do not need to memorise the exact commands. The View push commands button in the ECR console shows them, already filled in with your account and region.

On the server, the role from Lesson 5 needs permission to pull images. Attach the AWS-managed policy AmazonEC2ContainerRegistryReadOnly to myapp-ec2-role. The server then logs in and pulls in the same way, using the role and no keys.

Tag with the commit SHA, never rely on latest

myapp:latest is a label that moves to each new image. Two servers that pull “latest” an hour apart can run different code, and you can never say what production is running. myapp:a1b2c3d always points to one exact build, you can trace it to one commit, and a rollback means running the previous SHA. Add an ECR lifecycle rule (an automatic cleanup rule) to delete old images, so storage (about $0.10 per GB-month) does not keep growing.

Debugging containers

ProblemFirst commandWhat to look for
Container exits immediatelydocker ps -a then docker logs myappThe exit code and the last error. Exit 1 means the app crashed. Exit 137 means the container was killed, often because it ran out of memory
Cannot reach it from the browserdocker port myapp and ss -tlnpIs the port opened with -p? Does the app listen on 0.0.0.0 and not only on 127.0.0.1?
Env var missing or wrongdocker exec myapp envThe real environment variables that the process sees
Cannot reach RDS or another servicedocker exec -it myapp sh then wget -qO- host:portTest the network from inside the container
Disk fulldocker system dfHow much space images, the build cache and container logs use. Clean up with docker system prune -a
Image too bigdocker history --no-truncWhich layer added hundreds of MB
docker exec edits do not last

If you fix something by logging into a container, the fix disappears the next time the container is replaced. Containers are disposable. Make the fix in the Dockerfile or in the config, then rebuild and redeploy. This is a change of thinking. The server in Lesson 7 was like a pet that you care for one by one. Containers are like farm animals: you replace them, you do not repair them.

Ten mistakes to skip

MistakeWhy it hurts / the fix
Using the :latest tag in productionYou cannot tell which version is running, and you cannot roll back. Tag with the Git commit SHA (the short ID of a commit).
Running as root inside the containerIf an attacker breaks out, they have full power. Add a USER line with a normal user.
Copying .env or .git into the imageSecrets inside a layer can be read by anyone who pulls the image. Use .dockerignore and pass variables at run time.
COPY . . before npm installEvery code change reinstalls all dependencies. Copy the lockfile first.
Storing uploads or databases inside the containerThey are deleted with the container. Use volumes, S3 or RDS.
One giant image with Node, build tools and sourceIt is slow, large and has more to attack. Use a multi-stage build.
Forgetting the restart policyA crash or a reboot leaves the site down. Use --restart unless-stopped.
Binding the app to 127.0.0.1 inside the containerNothing outside the container can connect. Bind to 0.0.0.0.
Using localhost as the database host in ComposeInside a container, localhost means the container itself. Use the service name.
Never pruningOld images fill the 20 GB disk. Run docker system prune on a schedule.

Interview corner

What is the difference between an image and a container?

An image is a template that cannot be changed, made of layers. A container is a running (or stopped) copy of an image, with a thin writable layer on top. Many containers can start from one image.

How is a container different from a virtual machine?

A VM imitates hardware and runs its own kernel. A container shares the host kernel and separates processes using Linux namespaces (which hide other processes and files) and cgroups (which limit CPU and memory). Containers are smaller, start in a fraction of a second and pack more tightly on a machine, but the separation is weaker.

Why order Dockerfile instructions the way we did?

Layer caching. Docker reuses a layer if its instruction and everything above it are unchanged. Copy the dependency files and install first, and copy the source code last. Then a code edit does not reinstall the dependencies.

What is a multi-stage build and why use one?

It is a Dockerfile with several FROM stages. Only the last stage becomes the final image. Build tools, dev dependencies and source code stay in the earlier stages, so the final image is smaller and safer.

How do you handle secrets with Docker?

Never put them inside the image. Give them to the container at run time, as environment variables or as files from a secret store (SSM Parameter Store or Secrets Manager). Use the instance or task IAM role for AWS access. If a secret gets into a layer, replace it with a new one.

Your container keeps restarting. Walk me through it.

First run docker ps -a to see the exit code and restart count. Then run docker logs to read the error. Exit 137 points to running out of memory (raise the limit or fix the leak). Check the environment variables with docker exec … env. Then run the same image by hand to reproduce the problem.

Why not tag production images :latest?

The tag can be moved to a different image, so you cannot tell what is deployed. Servers can end up running different code, and a rollback is unclear. Tags that never change (a commit SHA or a version number) make deployments easy to trace and to undo.


Practice task before Lesson 10

  1. Add output: "standalone", the Dockerfile and .dockerignore to your Next.js repo. Build it and run it locally. Note the image size.
  2. Change one line of source, rebuild, and watch which steps say CACHED. Then change a dependency and see the difference.
  3. Write a docker-compose.yml with your app and Postgres. Run the app, create a row, run docker compose down then up and confirm the row survived. Then run down -v and confirm it did not.
  4. Create the ECR repository, push an image tagged with your Git SHA, and confirm it in the console. Read the scan results.
  5. On the EC2 server: install Docker, remove the app from PM2, pull your image and run it with the flags from this lesson. Confirm it survives sudo reboot.
  6. Explain in two sentences why -p 127.0.0.1:3000:3000 is safer than -p 3000:3000.
Optional stretch

Run docker scout quickview myapp:dev (or trivy image myapp:dev) to scan for known vulnerabilities. Try switching the base image from node:22-alpine to node:22-slim and compare size and findings.

Conclusion

Docker turns “a server only you can rebuild” into “an image anyone can run”. It is the link between the manual work of Lesson 7 and the automation of Lessons 12–15.

  • Dockerfile → image → container. Registry to move images between machines.
  • Layer order = build speed. Lockfile and install first, source last.
  • Ship small and safe: use a multi-stage build, standalone output, a non-root user and a proper .dockerignore.
  • Give config at run time, never inside the image. Take secrets from the environment or a secret store, and get AWS access from the IAM role.
  • Containers are disposable. State lives in RDS and S3; fixes go into the Dockerfile; tags are commit SHAs.

The app now runs in a container that listens on a private port. Next we put a proper receptionist in front of it: Nginx (a web server that receives requests and passes them to your app).


End of Lesson 9. Next: Lesson 10 — Nginx: Reverse Proxy.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series