<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Containerize Your App

Containerize Your App

Containerizing means packaging your app and its environment into an image, so it runs the same way on any machine with Docker. The container carries its own runtime (the program that runs your code, like Node.js), its dependencies (the packages in node_modules), and the system libraries the operating system provides, so it no longer matters what's installed on the host.

You describe that environment in a Dockerfile, build it into an image, and run the image as a container.

Writing a Dockerfile

Think about what you'd do to run this app on a brand-new machine: install Node, make a project folder, copy in the package files, run npm install, copy in the source, start the server. A Dockerfile is exactly that list of setup steps, written down so Docker can replay them. Each instruction builds on the one before, every instruction creates a layer, and Docker caches layers it has already built.

# Start from an official Node image
FROM node:20-alpine

# All following commands run inside this directory
WORKDIR /app

# Copy package files first, on their own, so npm install is cached
COPY package*.json ./
RUN npm install

# Now copy the rest of the source
COPY . .

# Documentation only — has no impact on the container's behavior at runtime
EXPOSE 3000

# The command that runs when the container starts
CMD ["node", "server.js"]

Dockerfile commands at a high level

A Dockerfile is a small set of instructions, each one a command in uppercase followed by its arguments. The example above uses six of them. Hover any highlighted keyword in the Dockerfile to see what it does, then use the table below as a quick reference for what each command does and when it runs. One detail worth noting on the first line: the -alpine tag picks a minimal base variant that keeps the image small.

At a glance, the six commands and when each takes effect:

Command What it does When it runs
FROM Pick the base image to build on top of Build time
WORKDIR Set the directory the following commands run in Build & runtime
COPY Copy files from your machine into the image Build time
RUN Execute a command and bake the result into a layer Build time
EXPOSE Document which port the app listens on Documentation only
CMD Set the command that runs when the container starts Runtime

Order your layers by how often they change

Copying package*.json and running npm install before copying your source is deliberate. Docker reuses a cached layer until one of its inputs changes. Your source changes on every edit; your dependencies rarely do. Put the rarely-changing step first and Docker skips reinstalling every package on each rebuild. This is layer caching at work.

Source copied first COPY . . RUN npm install Edit one line of source install layer is invalidated ✗ reinstalls every rebuild package.json copied first COPY package*.json ./ RUN npm install (cached) Edit one line of source install layer is unchanged ✓ reuses the cached install
Editing source invalidates every layer built after the COPY. Install dependencies first and a source edit reuses the cached npm install instead of repeating it.

In image builds you'll often see npm ci instead of npm install. npm ci installs exactly what's in the lockfile, skips the resolution step, and exits with an error if the lockfile is missing or out of date. That makes builds reproducible and catches dependency drift before it reaches production. The example above uses npm install for familiarity; prefer npm ci in real Dockerfiles.

Passing values in: ARG and ENV

  • ARG: a value available only while the image is being built. You set it with docker build --build-arg NODE_VERSION=20, and once the build finishes it's gone. Good for build-time choices like a version number.
  • ENV: an environment variable baked into the image and available to the app at runtime, the same way process.env.PORT works outside Docker. You can also override env vars when starting a container with docker run -e PORT=4000.
# Build-time value: which Node base image variant to use
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# Runtime value: available to the app as process.env.NODE_ENV
ENV NODE_ENV=production

The distinction (build-time vs. runtime) matters more than it looks. It's the foundation of the Secrets lesson later: where a value lives determines who can read it back out.

Run as a non-root user

By default, processes inside a container run as root, which amplifies the blast radius if something goes wrong. Add a USER instruction before CMD to drop privileges before the app starts. The official Node image already creates a node user for exactly this:

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

This is standard practice, not optional hardening. You'll see it checked in the exercise below.

Building and running

docker build turns the Dockerfile into an image. docker run starts a container from it.

# Build an image and tag it "my-api"
docker build -t my-api .

# Run a container, mapping host port 3000 to container port 3000
docker run -p 3000:3000 my-api

The -p host:container flag is what makes the app reachable. A container's ports are isolated by default. Without publishing one, the app is running but nothing on your machine can reach it.

Your machine localhost:3000 -p 3000:3000 Container app on port 3000
Without -p 3000:3000, the container's port is isolated. The app runs but nothing outside can reach it.

Exercise

Dockerfile challenge

Harden this Node.js Dockerfile

This Dockerfile builds but has problems. Fix the following:

  • Switch to an alpine base image
  • Add a working directory
  • Copy only the dependency manifests (package*.json) and run the install step, then copy the rest of the source — so Docker can cache the install layer
  • Run the container as a non-root user (USER node)
  • Define exactly one CMD to start the app

Check your understanding

Question 1 of 2

Port not reachable

You run `docker run my-api` and the app starts, but localhost:3000 won't connect. What's missing?