Ahnii!
A Dockerfile is a text file that tells Docker how to build an image. Each line is an instruction — what base to start from, what files to copy in, what commands to run. This post covers the fundamentals before we get to Compose or multi-stage builds.
Clone the companion repo to follow along.
Prerequisites
- Docker installed
- Basic terminal knowledge
Your First Dockerfile
Here’s a minimal Dockerfile for a Node.js app:
FROM node:25-alpine
WORKDIR /app
COPY . .
CMD ["node", "index.js"]
Four lines. Let’s break them down:
-
FROM node:25-alpine— Start from the official Node.js 25 image (Alpine variant for smaller size) -
WORKDIR /app— Set/appas the working directory for subsequent instructions -
COPY . .— Copy everything from your local directory into the container’s/app -
CMD ["node", "index.js"]— Run this command when the container starts
Build and run it:
docker build -t hello-docker .
docker run hello-docker
The -t flag tags the image with a name. The . tells Docker to use the current directory as the build context.
Core Dockerfile Instructions
FROM: Choose Your Base Image
Every Dockerfile starts with FROM. It sets the base image your container builds on.
FROM node:25-alpine
The tag (25-alpine) matters:
-
node:25— Full Debian-based image (~1GB) -
node:25-slim— Smaller Debian variant (~200MB) -
node:25-alpine— Alpine Linux base (~50MB)
Alpine images are smaller but use musl instead of glibc. Most Node.js apps work fine, but some native modules may need adjustment.
Always pin a specific version. FROM node:latest means your build could break tomorrow when a new version drops.
COPY vs ADD
Both copy files into the image. Use COPY unless you specifically need ADD’s extras.
COPY package.json ./
COPY src/ ./src/
ADD can also extract tar archives and fetch URLs, but that magic often causes confusion. Stick with COPY for clarity.
RUN: Execute Commands
RUN executes commands during the build. Use it to install dependencies:
RUN npm install --production
Each RUN creates a new layer. Combine related commands to reduce layers:
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
The cleanup at the end keeps the layer small — files deleted in the same RUN don’t bloat the image.
WORKDIR, ENV, EXPOSE
WORKDIR /app
ENV NODE_ENV=production
EXPOSE 3000
-
WORKDIR— Sets the directory forRUN,CMD,COPY, etc. Creates it if it doesn’t exist. -
ENV— Sets environment variables available at build time and runtime. -
EXPOSE— Documents which port the app listens on. It doesn’t publish the port — that’s what-pdoes at runtime.
CMD vs ENTRYPOINT
Both define what runs when the container starts. The difference is subtle but important.
CMD provides defaults that can be overridden:
CMD ["node", "index.js"]
# Runs node index.js
docker run hello-docker
# Overrides CMD — runs node --version instead
docker run hello-docker node --version
ENTRYPOINT sets a fixed command:
ENTRYPOINT ["node"]
CMD ["index.js"]
# Runs node index.js
docker run hello-docker
# Runs node --version (ENTRYPOINT stays, CMD is replaced)
docker run hello-docker --version
For most apps, CMD alone is fine. Use ENTRYPOINT when your container is a wrapper around a specific executable.
Understanding Layers and Caching
Docker caches each instruction. If a layer hasn’t changed, Docker reuses the cached version. This speeds up builds dramatically — but only if you order instructions wisely.
Bad order (cache busts on every code change):
FROM node:25-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
Any change to your code invalidates the COPY . . layer, which invalidates npm install. You reinstall dependencies every build.
Better order (dependencies cached separately):
FROM node:25-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
CMD ["node", "index.js"]
Now npm install only reruns when package.json changes. Code changes only affect the final COPY layer.
Common Mistakes
Running as Root
By default, containers run as root. That’s a security risk. Create a non-root user:
FROM node:25-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "index.js"]
The --chown flag sets ownership during copy. USER switches to that user for subsequent instructions and runtime.
Missing .dockerignore
Without a .dockerignore, COPY . . grabs everything — including node_modules, .git, and files you don’t want in your image.
Create a .dockerignore:
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
This keeps your build context small and your image clean.
Ignoring Layer Order
Covered above, but worth repeating: put instructions that change frequently at the bottom. Dependencies before code. Code before tests.
Try It Yourself
From the companion repo:
cd 01-dockerfile-basics
docker build -t hello-docker .
docker run -p 3000:3000 hello-docker
curl http://localhost:3000
You should see JSON with the container’s hostname, Node version, and uptime.
What’s Next
Part 2 covers multi-stage builds — how to use one Dockerfile to build your app in a full Node image, then copy the result into a minimal runtime image. Your production images get smaller and more secure.
Baamaapii
Want the complete guide? All 5 parts of Docker from Scratch as a formatted ebook, plus a Dockerfile cheat sheet and 3 production-ready templates (Node.js, Python, Go). Grab the bundle on Gumroad →
Top comments (0)