The first time I wrote a Dockerfile, I treated it like a shell script with a funny syntax, and I got away with it for a while. Once I started building images that other people depended on, though, I learned that a Dockerfile isn’t just “commands that happen to run in order” — it’s a layered, cacheable, reproducible build recipe with its own internal logic. This guide covers everything I wish someone had explained to me on day one: syntax, layer caching, multi-stage builds, and the small mistakes that quietly bloat images or break builds.
What a Dockerfile Actually Is
A Dockerfile is a plain text file containing instructions that the Docker daemon reads, one at a time, to produce a Docker image. Each instruction typically creates a new filesystem layer on top of the previous one. An image is really just a stack of these read-only layers plus some metadata (entrypoint, exposed ports, environment variables, etc.).
Prerequisites
- Docker Engine installed (
docker --version) - A terminal and a text editor
- Roughly 10 minutes and a small sample app
Step 1: Create a Simple Application
I’ll use a tiny Node.js app so the Dockerfile has something real to package.
app.js:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from my Dockerized app!\n');
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
package.json:
{
"name": "docker-demo-app",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js"
}
}
Step 2: Write the Dockerfile
# Base image
FROM node:20-alpine
# Set working directory inside the container
WORKDIR /usr/src/app
# Copy dependency manifests first (layer caching optimization)
COPY package*.json ./
# Install dependencies
RUN npm install --production
# Copy the rest of the application source
COPY . .
# Document which port the container listens on
EXPOSE 3000
# Run as a non-root user for security
USER node
# Default command when the container starts
CMD ["node", "app.js"]
Step 3: Understand Every Instruction
FROMsets the base image.node:20-alpinegives me a minimal Alpine Linux image with Node.js 20 preinstalled — far smaller thannode:20(Debian-based).WORKDIRsets the working directory for every instruction after it, and creates the directory if it doesn’t exist.COPYcopies files from the build context (the directory you rundocker buildfrom) into the image.RUNexecutes a command during the build and commits the result as a new layer.EXPOSEis documentation — it doesn’t actually publish the port; that happens with-patdocker runtime.USERswitches the user that subsequent instructions and the final container process run as.CMDdefines the default command executed when the container starts, unless overridden.
Step 4: Build the Image
docker build -t my-node-app:1.0 .
Expected output:
[+] Building 8.2s (10/10) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [internal] load metadata for docker.io/library/node:20-alpine
=> [1/5] FROM docker.io/library/node:20-alpine
=> [internal] load build context
=> [2/5] WORKDIR /usr/src/app
=> [3/5] COPY package*.json ./
=> [4/5] RUN npm install --production
=> [5/5] COPY . .
=> exporting to image
=> => naming to docker.io/library/my-node-app:1.0
The -t flag tags the image with a name and version. The trailing . tells Docker to use the current directory as the build context.
Step 5: Run and Verify
docker run -d -p 3000:3000 --name node-demo my-node-app:1.0
curl http://localhost:3000
Hello from my Dockerized app!
Why Instruction Order Matters: Layer Caching
Docker caches each layer and reuses it on subsequent builds if nothing that affects it has changed. That’s why COPY package*.json ./ and RUN npm install come before COPY . . — application source code changes far more often than dependency lists. If I copied everything at once, editing a single line of app.js would invalidate the cache for npm install, forcing a full reinstall on every build. With dependencies copied first, npm install only reruns when package.json or package-lock.json actually changes.
I can watch this in action by rebuilding after only touching app.js:
docker build -t my-node-app:1.0 .
=> CACHED [3/5] COPY package*.json ./
=> CACHED [4/5] RUN npm install --production
=> [5/5] COPY . .
Notice CACHED on the dependency steps — only the final COPY re-ran.
Multi-Stage Builds
For compiled languages, or to keep final images small, multi-stage builds let me use one stage to build the application and a second, minimal stage to run it:
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
# Stage 2: run
FROM alpine:3.19
COPY --from=builder /src/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
The final image contains only the compiled binary and Alpine’s minimal base — no Go toolchain, no source code, dramatically smaller and with a reduced attack surface.
.dockerignore
Just like .gitignore, a .dockerignore file keeps unnecessary files out of the build context, which speeds up builds and avoids accidentally baking in secrets or bloat:
node_modules
.git
*.log
.env
Dockerfile
Internal Working: Layers, Union Filesystem, and the Build Context
Every RUN, COPY, and ADD instruction produces a new layer, stored as a diff against the layer below it, using a union filesystem (overlay2 on modern Linux hosts). When a container starts from an image, Docker adds one more thin, writable layer on top — this is why containers are cheap to start and why changes inside a running container disappear when it’s removed unless committed or backed by a volume.
When I run docker build, the Docker daemon (or BuildKit, which is the default builder in modern Docker) first sends the entire build context to the daemon, then executes instructions one by one, checking its cache for each step based on the instruction plus the hash of any files it references.
Security Considerations
- Never run containers as root unless there’s a specific reason. The
USER nodeinstruction above avoids running the app with root privileges. - Avoid hardcoding secrets like API keys or passwords in
ENVorRUNinstructions — they persist in the image’s layer history and are visible viadocker history. - Use
--secretwith BuildKit for build-time secrets instead:
docker build --secret id=npmrc,src=$HOME/.npmrc -t my-node-app:1.0 .
- Scan images for known vulnerabilities:
docker scout cves my-node-app:1.0
Troubleshooting
“COPY failed: no such file or directory” This almost always means the file isn’t inside the build context, or it’s excluded by .dockerignore.
Image is much larger than expected Check layer sizes:
docker history my-node-app:1.0
Look for large RUN layers from package manager caches; clean them up in the same RUN instruction (apt-get clean && rm -rf /var/lib/apt/lists/*) since separate RUN lines don’t shrink earlier layers.
Build succeeds, but “permission denied” at runtime Usually caused by switching to a non-root USER before copying files that user doesn’t own. Fix by copying with --chown:
COPY --chown=node:node . .
Best Practices
- Pin base image versions (
node:20-alpine, notnode:latest) for reproducibility. - Order instructions from least to most frequently changing.
- Use multi-stage builds to keep final images minimal.
- Add a
.dockerignore. - Avoid unnecessary
RUNinstructions — combine related commands to reduce layer count. - Always specify
CMDorENTRYPOINTexplicitly.
Summary
A Dockerfile is a declarative, layered build recipe, and understanding how caching, layering, and build context work turns it from a black box into a precise tool. Getting instruction order right, using multi-stage builds, and avoiding baked-in secrets are the habits that separate a fragile Dockerfile from one that’s fast to build, small to ship, and safe to run.
References
- Dockerfile reference: https://docs.docker.com/reference/dockerfile/
- Docker build documentation: https://docs.docker.com/build/
- Best practices for writing Dockerfiles: https://docs.docker.com/build/building/best-practices/
- BuildKit documentation: https://docs.docker.com/build/buildkit/
