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:

FilePurpose
DockerfileInstructions for building the image
.dockerignoreFiles to exclude from the build context
compose.yamlOptional 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 .dockerignore like .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:

SectionTypical InstructionsWhy It Exists
BaseFROMSelects the starting filesystem and runtime
Working directoryWORKDIRSets a predictable path for later instructions
Dependency filesCOPY package*.json ./Lets dependency install layers be cached when app code changes
Build commandsRUN ...Installs packages, compiles assets, creates files
App filesCOPY . .Copies the application code into the image
Runtime metadataEXPOSE, ENV, CMDDocuments/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 source

When only app source changes, dependency installation can often stay cached.

WORKDIR

WORKDIR sets the working directory for later Dockerfile instructions.

WORKDIR /app

After this, relative paths in later instructions are interpreted from /app.

WORKDIR affects instructions such as:

InstructionHow WORKDIR Affects It
RUNCommands run from the working directory
COPYRelative destination paths are based on the working directory
ADDRelative destination paths are based on the working directory
CMDDefault process starts from the working directory
ENTRYPOINTEntrypoint 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 pwd

The 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:

InstructionResult
WORKDIR /appSet 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.js

If you use an absolute destination path, it ignores WORKDIR:

WORKDIR /app
COPY config.yaml /etc/my-app/config.yaml

That 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 pwd

The final directory is:

/a/b/c

Prefer absolute WORKDIR paths in beginner Dockerfiles because they are easier to read.

Key Insight: WORKDIR /app does not copy files by itself. It only sets the current directory for later instructions. COPY is 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=/app

The basic shape is:

ENV KEY=value

You can set multiple variables in one instruction:

ENV APP_ENV=production \
    PORT=3000 \
    APP_HOME=/app

Values can include spaces when quoted or escaped:

ENV APP_NAME="Example API"
ENV APP_DESCRIPTION=Small\ demo\ service

What 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
PlaceCan Use ENV Value?Example
Later Dockerfile instructionsYesWORKDIR $APP_HOME
Build-time shell commandsYesRUN echo "$APP_ENV"
Running container processYesApp reads APP_ENV
docker inspect outputYesEnvironment 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 CaseExampleWhy
Default app modeENV APP_ENV=productionGives the app a default environment
Default app portENV PORT=3000Lets the app know which port to listen on
App directoryENV APP_HOME=/appAvoids repeating /app everywhere
Add executable pathENV PATH="/app/bin:${PATH}"Makes installed tools easier to run
Runtime behavior defaultsENV LOG_LEVEL=infoProvides 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:

InstructionResult
ENV APP_HOME=/appStore /app in an environment variable
WORKDIR $APP_HOMESet 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=info

Run with defaults:

docker run my-app:1.0

Override at runtime:

docker run -e APP_ENV=dev -e LOG_LEVEL=debug my-app:1.0

The 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 python3

This 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: ENV is for persistent defaults. ARG or 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:

InstructionMeaning
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=dev

Any source change can invalidate the dependency install step.

Tip: Keep .dockerignore clean. 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/data

Use RUN for build-time work:

Use RUN ForExample
Installing OS packagesRUN apk add --no-cache curl
Installing app dependenciesRUN npm ci --omit=dev
Compiling/building assetsRUN npm run build
Creating directories/files needed by the imageRUN 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 here

Shell Form vs Exec Form

Shell form:

RUN npm ci --omit=dev

Exec 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/udp

EXPOSE 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 3000

Use EXPOSE for documentation and tooling metadata:

UseExample
Document an app portEXPOSE 3000
Specify protocol explicitlyEXPOSE 53/udp
Help readers understand expected runtime mappingPair 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.0

In that command:

8080:3000
|    |
|    +-- container port
+------- host port

Key Insight: EXPOSE tells people and tools what port the app expects. docker run -p makes 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 ForExample
Starting the appCMD ["node", "server.js"]
Running a web serverCMD ["nginx", "-g", "daemon off;"]
Providing a default shell for a utility imageCMD ["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 command

Only 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 bash

In both examples, the command after the image name replaces the image’s default CMD.

RUN vs CMD

QuestionRUNCMD
When does it execute?During docker buildWhen a container starts
What is it for?Preparing the imageStarting the default process
Does it create image content?Often yesNo, it stores runtime metadata
Can docker run IMAGE command override it?NoYes

Key Insight: RUN shapes the image. CMD tells 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 context

The final . is important. It tells Docker which directory to use as the build context.

Useful options:

OptionUse
-t, --tagName and tag the image
-f, --fileUse a Dockerfile with a different path/name
--build-argPass build-time variables
--no-cacheIgnore build cache
--pullTry to pull a newer base image
--targetBuild 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 .dockerignore

Dockerfile 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 .dockerignore aggressively.

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 needed

This 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

  • Dockerfile is the build recipe for an image.
  • .dockerignore controls what is excluded from the build context.
  • Image layers let Docker reuse unchanged build work.
  • WORKDIR sets the working directory for later instructions.
  • ENV sets environment variables that persist into the image and can be overridden at runtime.
  • COPY brings files from the build context into the image.
  • RUN executes during docker build and shapes the image.
  • EXPOSE documents expected ports; it does not publish them by itself.
  • CMD is 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 RUN and CMD.

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.