Blog
3 min read

Dockerfile Explained: Every Line of a Simple Dockerfile

A Dockerfile is the recipe Docker follows to build an image. A line-by-line walkthrough of a real Dockerfile — FROM, WORKDIR, COPY, RUN, ENV, EXPOSE, CMD — why the order matters for caching, .dockerignore, and the beginner mistakes that make images slow, huge or insecure.

A Dockerfile is a text file of instructions Docker follows to build an image — a packaged copy of your app with everything it needs to run. (What is Docker?, Docker image vs container)

Here's a simple, sensible Dockerfile for a Node.js app, explained line by line.

FROM node:24-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000

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

FROM — the starting point

FROM node:24-slim

Every image builds on another. node:24-slim is an official image with Node.js 24 on a small Debian Linux. For Python you'd use python:3.13-slim.

  • Pin a version (24-slim), not latest, so builds don't change unexpectedly.
  • -slim and -alpine variants are much smaller than the default. Alpine is smallest but uses a different C library, which occasionally breaks packages with native code.

WORKDIR — where things happen

WORKDIR /app

Sets the folder inside the image for the following commands (and creates it). Like cd, but it sticks.

COPY the dependency list first

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

Why copy these two files separately, before the rest? Caching.

Docker caches each step. If a step's inputs haven't changed, it reuses the cached result. Your dependencies change rarely; your code changes constantly. Copying just the package files first means npm ci is re-run only when dependencies change. Copy everything first and every code change triggers a full reinstall.

npm ci installs exactly what's in the lock file; --omit=dev skips development-only packages. (package-lock.json explained)

RUN — execute a command at build time

RUN runs a command while building the image — installing packages, compiling, creating folders. Each RUN creates a new layer. (Docker image layers)

COPY the rest

COPY . .

Copies your project into /app. Combined with a .dockerignore file, so junk and secrets stay out:

node_modules
.git
.env
dist
*.log

Without it, you copy your local node_modules (wrong OS, wrong architecture) and possibly your .env with production secrets into the image. (exec format error)

ENV — environment variables

ENV NODE_ENV=production

Sets a variable available at build and run time. Don't put secrets here — they're stored in the image for anyone who has it. Pass secrets at run time instead (docker run -e, Compose .env). (Environment variables explained)

EXPOSE — documentation

EXPOSE 3000

Says which port the app listens on. It doesn't actually publish the port — you still do that with -p 3000:3000. Your app must listen on 0.0.0.0, not localhost, to be reachable from outside the container.

USER — don't run as root

USER node

By default containers run as root. The official Node image includes a node user; switching to it limits the damage if the app is compromised. (Container hardening)

CMD — what runs when the container starts

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

The command the container runs. Use the JSON array form so the process receives shutdown signals properly. (PID 1 in containers)

RUN = at build time. CMD = at start time. Mixing them up is a classic beginner bug.

Build and run

docker build -t myapp .
docker run -p 3000:3000 --env-file .env myapp

Going further


EasySpawn can build and run your app from its Dockerfile on your own server — or run it without one — with HTTPS, a database and backups around it. See how it works or join the waitlist.

Related: What Is Docker? · Docker Image vs Container · Writing a Production Dockerfile for Node.js · How to Reduce Docker Image Size

Keep reading