A Dockerfile is a build recipe for a container image. It tells Docker which base image to start from, what files to copy, what commands to run during the build, and what command should run when a container starts.
This note goes deeper than Dockerfile, Build, Image, Run.
For the difference between changing a running container and updating an image, see Container Changes and Image Updates.
Docker Files in a Project
Most Docker-based projects have at least these files:
| File | Purpose |
|---|---|
Dockerfile | Instructions for building the image |
.dockerignore | Files to exclude from the build context |
compose.yaml | Optional multi-container local/dev setup |
By convention, the main file is named exactly Dockerfile, with no extension.
app/
Dockerfile
.dockerignore
package.json
src/Tip: Use
.dockerignorelike.gitignore, but for Docker builds. Exclude files that should not be sent to the builder, such as local caches, temporary files, credentials, and large build outputs.
Build Flow
flowchart TD Project["Project folder"] --> Context["Build context"] Dockerfile["Dockerfile"] --> Build["docker build"] Context --> Build Base["Base image"] --> Build Build --> Layers["Image layers"] Layers --> Image["Final image"] Image --> Run["docker run"] Run --> Container["Running container"]
docker build reads the Dockerfile and build context, executes build instructions, and produces an image.
Dockerfile Anatomy
Example:
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Common structure:
| Section | Typical Instructions | Why It Exists |
|---|---|---|
| Base | FROM | Selects the starting filesystem and runtime |
| Working directory | WORKDIR | Sets a predictable path for later instructions |
| Dependency files | COPY package*.json ./ | Lets dependency install layers be cached when app code changes |
| Build commands | RUN ... | Installs packages, compiles assets, creates files |
| App files | COPY . . | Copies the application code into the image |
| Runtime metadata | EXPOSE, ENV, CMD | Documents/defaults for running the container |
Image Layers
An image is built as a stack of layers plus metadata. Filesystem-changing instructions create new content that Docker can reuse through the build cache.
Final image
+----------------------------------+
| CMD metadata |
+----------------------------------+
| COPY . . |
+----------------------------------+
| RUN npm ci --omit=dev |
+----------------------------------+
| COPY package*.json ./ |
+----------------------------------+
| WORKDIR /app metadata |
+----------------------------------+
| FROM node:lts-alpine |
+----------------------------------+This layer model is why Dockerfile order matters.
If a layer changes, Docker may need to rebuild that layer and later layers. If earlier layers remain unchanged, Docker can often reuse them from cache.
Good cache pattern
COPY dependency manifests
RUN install dependencies
COPY app sourceWhen only app source changes, dependency installation can often stay cached.
WORKDIR
WORKDIR sets the working directory for later Dockerfile instructions.
WORKDIR /appAfter this, relative paths in later instructions are interpreted from /app.
WORKDIR affects instructions such as:
| Instruction | How WORKDIR Affects It |
|---|---|
RUN | Commands run from the working directory |
COPY | Relative destination paths are based on the working directory |
ADD | Relative destination paths are based on the working directory |
CMD | Default process starts from the working directory |
ENTRYPOINT | Entrypoint process starts from the working directory |
If the directory does not exist, Docker creates it during the build.
FROM node:lts-alpine
WORKDIR /app
RUN pwdThe RUN pwd command runs from /app.
Why Use WORKDIR?
Without WORKDIR, you need to use absolute paths repeatedly:
FROM node:lts-alpine
COPY package*.json /app/
RUN cd /app && npm ci --omit=dev
COPY . /app/
CMD ["node", "/app/server.js"]With WORKDIR, the Dockerfile is cleaner and less error-prone:
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]WORKDIR with COPY
This is the most common beginner confusion.
WORKDIR /app
COPY package*.json ./
COPY src/ ./src/
COPY . .Read it like this:
| Instruction | Result |
|---|---|
WORKDIR /app | Set current build directory to /app |
COPY package*.json ./ | Copy matching files into /app/ |
COPY src/ ./src/ | Copy local src into /app/src/ |
COPY . . | Copy the build context into /app/ |
Visual:
Build context on host
project/
package.json
src/
server.js
Dockerfile
WORKDIR /app
COPY package*.json ./
COPY src/ ./src/
Image filesystem result
/app/
package.json
src/
server.jsIf you use an absolute destination path, it ignores WORKDIR:
WORKDIR /app
COPY config.yaml /etc/my-app/config.yamlThat copies to /etc/my-app/config.yaml, not /app/etc/my-app/config.yaml.
Relative WORKDIR
You can use WORKDIR multiple times. If the next WORKDIR is relative, it is based on the previous one.
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwdThe final directory is:
/a/b/cPrefer absolute WORKDIR paths in beginner Dockerfiles because they are easier to read.
Key Insight:
WORKDIR /appdoes not copy files by itself. It only sets the current directory for later instructions.COPYis what actually brings files into the image.
ENV
ENV sets environment variables in the image.
ENV APP_ENV=production
ENV PORT=3000
ENV APP_HOME=/appThe basic shape is:
ENV KEY=valueYou can set multiple variables in one instruction:
ENV APP_ENV=production \
PORT=3000 \
APP_HOME=/appValues can include spaces when quoted or escaped:
ENV APP_NAME="Example API"
ENV APP_DESCRIPTION=Small\ demo\ serviceWhat ENV Affects
An ENV value is available to later Dockerfile instructions and persists into containers created from the image.
Dockerfile
|
| ENV APP_ENV=production
v
later build instructions can use APP_ENV
|
v
final image stores APP_ENV
|
v
docker run creates a container with APP_ENV available| Place | Can Use ENV Value? | Example |
|---|---|---|
| Later Dockerfile instructions | Yes | WORKDIR $APP_HOME |
| Build-time shell commands | Yes | RUN echo "$APP_ENV" |
| Running container process | Yes | App reads APP_ENV |
docker inspect output | Yes | Environment values are visible in image/container metadata |
Warning: Do not put secrets in
ENV. Values persist in the image and can be inspected. Use runtime secrets or secret mounts for sensitive data.
Common ENV Use Cases
| Use Case | Example | Why |
|---|---|---|
| Default app mode | ENV APP_ENV=production | Gives the app a default environment |
| Default app port | ENV PORT=3000 | Lets the app know which port to listen on |
| App directory | ENV APP_HOME=/app | Avoids repeating /app everywhere |
| Add executable path | ENV PATH="/app/bin:${PATH}" | Makes installed tools easier to run |
| Runtime behavior defaults | ENV LOG_LEVEL=info | Provides sane defaults that can be overridden |
ENV with WORKDIR and COPY
This pattern keeps paths consistent:
FROM node:lts-alpine
ENV APP_HOME=/app
WORKDIR $APP_HOME
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Read it like this:
| Instruction | Result |
|---|---|
ENV APP_HOME=/app | Store /app in an environment variable |
WORKDIR $APP_HOME | Set working directory to /app |
COPY package*.json ./ | Copy dependency files into /app/ |
COPY . . | Copy the build context into /app/ |
Visual:
ENV APP_HOME=/app
WORKDIR $APP_HOME
COPY . .
Result:
/app/
package.json
server.js
src/The ENV variable does not copy anything by itself. It only stores a value that later instructions can reference.
Runtime Overrides
ENV sets defaults in the image, but you can override them when starting a container.
Dockerfile:
ENV APP_ENV=production
ENV LOG_LEVEL=infoRun with defaults:
docker run my-app:1.0Override at runtime:
docker run -e APP_ENV=dev -e LOG_LEVEL=debug my-app:1.0The image still has the default values, but that container starts with the overridden values.
ENV vs Build-Only Values
Use ENV when the value should exist in the final image/container.
Do not use ENV for values needed only during one build command:
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends python3This keeps the temporary setting local to that RUN instruction.
For build-time values that should not persist into the final container, use ARG instead:
ARG APP_VERSION=dev
RUN echo "Building version $APP_VERSION"Key Insight:
ENVis for persistent defaults.ARGor one-command environment variables are better for build-only values.
COPY
COPY copies files or directories into the image filesystem.
COPY package*.json ./
COPY src/ ./src/
COPY . .The basic shape is:
COPY <source-from-build-context> <destination-in-image>Examples:
| Instruction | Meaning |
|---|---|
COPY package*.json ./ | Copy dependency manifest files into the current WORKDIR |
COPY src/ ./src/ | Copy the local src directory into /app/src if WORKDIR /app |
COPY . . | Copy everything in the build context into the current WORKDIR |
COPY ["file with spaces.txt", "/app/"] | JSON form, useful when paths contain spaces |
COPY reads from the build context. If a file is outside the build context, Docker cannot copy it with a normal COPY.
docker build -t my-app:1.0 .
|
v
build context is "."
COPY can read files inside "."Why COPY Order Matters
This pattern is common:
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .It helps cache dependency installation. If only application source changes, Docker can often reuse the dependency-install layer.
This pattern is less cache-friendly:
COPY . .
RUN npm ci --omit=devAny source change can invalidate the dependency install step.
Tip: Keep
.dockerignoreclean.COPY . .copies everything in the build context that is not ignored, so avoid sending credentials, local caches, logs, and build output.
RUN
RUN executes a command while the image is being built. The result becomes part of the image.
RUN npm ci --omit=dev
RUN apk add --no-cache curl
RUN mkdir -p /app/dataUse RUN for build-time work:
Use RUN For | Example |
|---|---|
| Installing OS packages | RUN apk add --no-cache curl |
| Installing app dependencies | RUN npm ci --omit=dev |
| Compiling/building assets | RUN npm run build |
| Creating directories/files needed by the image | RUN mkdir -p /app/logs |
RUN does not run when a container starts. It runs during docker build.
docker build
|
v
RUN executes here
docker run
|
v
RUN does not execute hereShell Form vs Exec Form
Shell form:
RUN npm ci --omit=devExec form:
RUN ["npm", "ci", "--omit=dev"]Shell form is common and readable. Exec form avoids shell interpretation and can be useful when you need exact argument handling.
EXPOSE
EXPOSE documents which port the containerized application is expected to listen on.
EXPOSE 3000
EXPOSE 80/tcp
EXPOSE 53/udpEXPOSE does not publish the port to your host machine by itself.
Dockerfile:
EXPOSE 3000
|
v
image metadata says: app expects port 3000
Runtime:
docker run -p 8080:3000 my-app
|
v
host port 8080 maps to container port 3000Use EXPOSE for documentation and tooling metadata:
| Use | Example |
|---|---|
| Document an app port | EXPOSE 3000 |
| Specify protocol explicitly | EXPOSE 53/udp |
| Help readers understand expected runtime mapping | Pair with docker run -p HOST:CONTAINER in docs |
To actually publish a port when running a container, use -p:
docker run -p 8080:3000 my-app:1.0In that command:
8080:3000
| |
| +-- container port
+------- host portKey Insight:
EXPOSEtells people and tools what port the app expects.docker run -pmakes that port reachable from the host.
CMD
CMD defines the default command for containers started from the image.
CMD ["node", "server.js"]Use CMD for the default runtime process:
Use CMD For | Example |
|---|---|
| Starting the app | CMD ["node", "server.js"] |
| Running a web server | CMD ["nginx", "-g", "daemon off;"] |
| Providing a default shell for a utility image | CMD ["sh"] |
CMD does not execute during docker build. It is used when a container starts.
docker build
|
v
CMD stored as image metadata
docker run IMAGE
|
v
CMD becomes the default container commandOnly one CMD is effective. If a Dockerfile has multiple CMD instructions, the last one wins.
You can override CMD at runtime:
docker run my-app:1.0 node worker.js
docker run -it ubuntu:24.04 bashIn both examples, the command after the image name replaces the image’s default CMD.
RUN vs CMD
| Question | RUN | CMD |
|---|---|---|
| When does it execute? | During docker build | When a container starts |
| What is it for? | Preparing the image | Starting the default process |
| Does it create image content? | Often yes | No, it stores runtime metadata |
Can docker run IMAGE command override it? | No | Yes |
Key Insight:
RUNshapes the image.CMDtells Docker what to run by default from that image.
docker build
Basic build:
docker build -t my-app:1.0 .Command anatomy:
docker build
-t my-app:1.0 image name and tag
. build contextThe final . is important. It tells Docker which directory to use as the build context.
Useful options:
| Option | Use |
|---|---|
-t, --tag | Name and tag the image |
-f, --file | Use a Dockerfile with a different path/name |
--build-arg | Pass build-time variables |
--no-cache | Ignore build cache |
--pull | Try to pull a newer base image |
--target | Build a specific stage in a multi-stage Dockerfile |
Examples:
docker build -t my-app:1.0 .
docker build -f Dockerfile.dev -t my-app:dev .
docker build --build-arg NODE_ENV=production -t my-app:prod .
docker build --no-cache -t my-app:fresh .
docker build --pull -t my-app:latest .Build Context
The build context is the set of files available to the build.
docker build -t my-app:1.0 .Here, . means “send the current directory as the build context.”
Build context
|
+-- Dockerfile
+-- package.json
+-- src/
+-- files not excluded by .dockerignoreDockerfile instructions like COPY can only copy files from the build context, unless you use more advanced build features.
Warning: Do not send secrets in the build context. If a file is inside the context, it may become available to the build. Use
.dockerignoreaggressively.
Build Cache Mental Model
Docker can reuse previous build results when instructions and their inputs have not changed.
FROM node:lts-alpine cache hit
COPY package*.json ./ cache hit
RUN npm ci --omit=dev cache hit
COPY . . source changed, rebuild from here
CMD ["node", "server.js"] metadata updated if neededThis is why dependency files are often copied before application source. If app source changes but dependency manifests stay the same, dependency installation can often remain cached.
TL;DR
Dockerfileis the build recipe for an image..dockerignorecontrols what is excluded from the build context.- Image layers let Docker reuse unchanged build work.
WORKDIRsets the working directory for later instructions.ENVsets environment variables that persist into the image and can be overridden at runtime.COPYbrings files from the build context into the image.RUNexecutes duringdocker buildand shapes the image.EXPOSEdocuments expected ports; it does not publish them by itself.CMDis the default command used when a container starts.docker build -t name:tag .builds an image from the Dockerfile and context.- Dockerfile order matters because earlier layer changes can invalidate later cache.
Resources
Dockerfile overview Official Docker explanation of Dockerfiles, layers, and common instructions.
Dockerfile reference Official reference for Dockerfile instructions including
RUNandCMD.Build context Official explanation of build context and
.dockerignore.Docker build cache Official explanation of how Docker build cache works.
docker buildx build Official CLI reference for build options.