Learn

/

Dockerfile Basics

Dockerfile Basics

5 patterns

FROM, RUN, COPY, ENTRYPOINT, and CMD instructions for building container images. You'll hit this when your container starts but runs the wrong command or ignores signals.

Avoid
FROM node:20-alpine

# Add application files
ADD . /app
WORKDIR /app

RUN npm install
CMD ["node", "server.js"]
FROM node:20-alpine

# Add application files
ADD . /app
WORKDIR /app

RUN npm install
CMD ["node", "server.js"]

Prefer
FROM node:20-alpine

# Copy application files
COPY . /app
WORKDIR /app

RUN npm install
CMD ["node", "server.js"]
FROM node:20-alpine

# Copy application files
COPY . /app
WORKDIR /app

RUN npm install
CMD ["node", "server.js"]
Why avoid

ADD has implicit behavior: it auto-extracts compressed archives and can fetch remote URLs. This makes builds less predictable. Docker's own best practices recommend COPY for plain file copying.

Why prefer

COPY is explicit and predictable: it copies files from the build context into the image. Use COPY unless you specifically need ADD's extra features (auto-extracting tarballs or fetching remote URLs). Most builds only need COPY.

Docker docs: ADD or COPY
Avoid
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install

# Shell form
CMD npm start
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install

# Shell form
CMD npm start

Prefer
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install

# Exec form
CMD ["npm", "start"]
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install

# Exec form
CMD ["npm", "start"]
Why avoid

The shell form (CMD npm start) wraps the command in /bin/sh -c, which means the shell process (PID 1) receives signals instead of your app. Your container won't shut down gracefully because the app never gets SIGTERM.

Why prefer

The exec form (CMD ["npm", "start"]) runs the command directly without a shell wrapper. This means the process receives signals like SIGTERM properly, enabling graceful shutdown. It also avoids unexpected shell variable expansion.

Docker docs: CMD
Avoid
FROM python:3.12-slim

COPY app.py /app/app.py
WORKDIR /app

# Hardcoded command, can't override args
ENTRYPOINT ["python", "app.py", "--port", "8080"]
FROM python:3.12-slim

COPY app.py /app/app.py
WORKDIR /app

# Hardcoded command, can't override args
ENTRYPOINT ["python", "app.py", "--port", "8080"]

Prefer
FROM python:3.12-slim

COPY app.py /app/app.py
WORKDIR /app

# Fixed binary, overridable defaults
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
FROM python:3.12-slim

COPY app.py /app/app.py
WORKDIR /app

# Fixed binary, overridable defaults
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
Why avoid

Putting all arguments in ENTRYPOINT means users must use --entrypoint to change anything, which replaces the entire command. This makes the image inflexible and harder to use in different environments.

Why prefer

Splitting ENTRYPOINT (the fixed executable) from CMD (the default arguments) lets users override arguments at runtime with docker run myimage --port 9090 without replacing the entire command. This is the standard pattern for flexible container images.

Docker docs: CMD and ENTRYPOINT
Avoid
FROM ubuntu:24.04

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y wget
RUN rm -rf /var/lib/apt/lists/*
FROM ubuntu:24.04

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y wget
RUN rm -rf /var/lib/apt/lists/*

Prefer
FROM ubuntu:24.04

RUN apt-get update && \
    apt-get install -y \
      curl \
      git \
      wget && \
    rm -rf /var/lib/apt/lists/*
FROM ubuntu:24.04

RUN apt-get update && \
    apt-get install -y \
      curl \
      git \
      wget && \
    rm -rf /var/lib/apt/lists/*
Why avoid

Each RUN creates a new layer. The apt-get update layer is separate from the install layers, so the package index can go stale in cached builds. The cleanup in a separate RUN doesn't reduce image size because the files still exist in earlier layers.

Why prefer

Combining related commands in a single RUN instruction creates one layer instead of five. The cleanup (rm -rf /var/lib/apt/lists/*) actually removes files from the image because it happens in the same layer as the install. Fewer layers also mean a smaller image.

Docker docs: Minimize layers
Avoid
FROM node:20-alpine

RUN mkdir -p /app
RUN cd /app && npm init -y
RUN cd /app && npm install express
COPY server.js /app/server.js
RUN cd /app && node -e "require('./server')"

CMD ["node", "/app/server.js"]
FROM node:20-alpine

RUN mkdir -p /app
RUN cd /app && npm init -y
RUN cd /app && npm install express
COPY server.js /app/server.js
RUN cd /app && node -e "require('./server')"

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

Prefer
FROM node:20-alpine

WORKDIR /app
RUN npm init -y
RUN npm install express
COPY server.js .
RUN node -e "require('./server')"

CMD ["node", "server.js"]
FROM node:20-alpine

WORKDIR /app
RUN npm init -y
RUN npm install express
COPY server.js .
RUN node -e "require('./server')"

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

cd inside RUN only affects that single RUN instruction. Each new RUN starts from / again unless you repeat the cd. This leads to repetitive code and easy-to-miss bugs when you forget the cd in one layer.

Why prefer

WORKDIR sets the working directory for all subsequent instructions. It creates the directory if it doesn't exist and persists across layers. This eliminates repetitive cd commands and makes paths relative to the app directory.

Docker docs: WORKDIR