WORKDIR, ENV, and ARG

WORKDIR, ENV, and ARG are Dockerfile instructions that control where commands run and how configuration reaches an image. They look small, but they affect every later layer in the build, the default runtime environment of containers, and how repeatable your images are.

This lesson teaches when to use each instruction, what is stored in the final image, and how to avoid common mistakes such as baking secrets into layers or relying on a working directory that was never set.

Overview: How it works

A Dockerfile is executed top to bottom. Each instruction produces image metadata, a filesystem layer, or both. Docker stores the result as a stack of read-only layers; when you run a container, Docker adds a thin writable layer on top and starts the configured process. WORKDIR, ENV, and ARG shape that build process in different ways.

WORKDIR sets the working directory for later Dockerfile instructions such as RUN, COPY, ADD, CMD, and ENTRYPOINT. If the directory does not exist, Docker creates it in the image filesystem. This is usually better than writing RUN cd /app && ... because each Dockerfile instruction runs independently; a cd inside one RUN does not carry into the next instruction.

ENV sets environment variables that are stored in image metadata and available to later build instructions and to containers started from the image. This makes it suitable for harmless defaults such as NODE_ENV=production, PORT=8080, or PYTHONUNBUFFERED=1. It is not suitable for secrets because image metadata and layer history can be inspected after the image is built.

ARG defines a build-time variable. You can provide a value with docker build --build-arg, and the Dockerfile can use it during the build. Unlike ENV, an ARG is not automatically available when a container runs. Use ARG for choices that affect the image build, such as a package version, feature flag, or base image tag. Use ENV for configuration that should be present at runtime.

Syntax

WORKDIR /absolute/or/relative/path
ENV NAME=value
ENV NAME=value OTHER_NAME=other-value
ARG NAME
ARG NAME=default-value
Instruction Purpose Scope
WORKDIR Sets the current directory for following Dockerfile instructions and the default container process. Build and runtime metadata.
ENV Sets environment variables saved in the image. Build steps after the instruction and running containers.
ARG Declares a variable accepted by docker build --build-arg. Build only, unless copied into ENV.

WORKDIR can be absolute, such as /app, or relative, such as src. A relative value is appended to the previous working directory. For clarity, most production Dockerfiles use an absolute first WORKDIR.

ENV values are persisted. If you write ENV APP_ENV=production, a later RUN can read $APP_ENV, and a container started from the image can read it too. A runtime value passed with docker run -e APP_ENV=staging overrides the image default for that container.

ARG values must be declared before use. An ARG declared before the first FROM can be used in FROM, but if you need that value after FROM, declare it again in the build stage.

Examples

Example 1: Set the application directory

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

Output:

Sending build context to Docker daemon  18.43kB
Step 1/6 : FROM node:20-alpine
Step 2/6 : WORKDIR /app
Step 3/6 : COPY package*.json ./
Step 4/6 : RUN npm ci --omit=dev
Step 5/6 : COPY . .
Step 6/6 : CMD ["node", "server.js"]
Successfully tagged my-node-app:1.0

The important detail is WORKDIR /app. The COPY package*.json ./ instruction copies files into /app, not into the filesystem root. The RUN npm ci --omit=dev command also runs from /app. When the container starts, node server.js runs with /app as its working directory.

Example 2: Provide runtime defaults with ENV

FROM alpine:3.20
WORKDIR /srv/status
ENV PORT=8080 LOG_LEVEL=info
RUN printf 'configured at build time
' > message.txt
CMD ["sh", "-c", "echo PORT=$PORT LOG_LEVEL=$LOG_LEVEL && cat message.txt"]

Output:

PORT=8080 LOG_LEVEL=info
configured at build time

The image has default values for PORT and LOG_LEVEL. Those values are not files in your project; they are image metadata. A user can still override them per container with docker run -e LOG_LEVEL=debug ..., which is useful because one image can run in development, staging, and production with different runtime settings.

Example 3: Use ARG during the build

FROM alpine:3.20
ARG APP_HOME=/opt/report
ARG APP_VERSION=1.0.0
WORKDIR ${APP_HOME}
ENV APP_VERSION=${APP_VERSION}
RUN printf 'version=%s
' "$APP_VERSION" > version.txt
CMD ["sh", "-c", "pwd && cat version.txt"]

Build it with a custom argument:

docker build --build-arg APP_VERSION=2.3.0 -t report-tool:2.3.0 .

Output:

#5 [2/4] WORKDIR /opt/report
#6 [3/4] RUN printf 'version=%s
' "2.3.0" > version.txt
#7 exporting to image
#7 naming to docker.io/library/report-tool:2.3.0 done

Here APP_HOME controls where build commands run, and APP_VERSION is copied into ENV so the running container can read it later. Without the ENV APP_VERSION=${APP_VERSION} line, the build could use the value, but the final container environment would not automatically include it.

Example 4: Override ENV at runtime

docker run --rm -e LOG_LEVEL=debug report-tool:2.3.0

Output:

/opt/report
version=2.3.0

This command demonstrates the precedence rule: values from docker run -e override image defaults for that one container. The example command changes LOG_LEVEL, while APP_VERSION still comes from the image because it was set with ENV during the build.

How it works step by step

  1. Docker reads the Dockerfile and sends the build context to the daemon or BuildKit builder. The build context is the set of local files available to COPY and ADD.
  2. When Docker reaches ARG APP_VERSION=1.0.0, it records a build variable and uses either the default value or the value supplied by --build-arg.
  3. When Docker reaches WORKDIR /app, it updates image metadata and creates that directory in the current filesystem layer if needed.
  4. When Docker reaches ENV NODE_ENV=production, it updates image metadata. Later RUN instructions and future containers inherit that value unless it is overridden.
  5. Each instruction participates in the build cache. Changing an ARG value can invalidate the first layer that uses it and all later layers. Changing an ENV line also invalidates that instruction and every instruction after it.
  6. When a container starts, Docker combines the image layers with a writable container layer, applies the image metadata such as working directory and environment defaults, applies runtime overrides such as -e, and starts the configured process.

This cache behavior matters. Put rarely changing setup before frequently changing source files. For a Node app, copy package.json and install dependencies before copying the rest of the application. That way, changing server.js does not force Docker to reinstall all dependencies.

Common Mistakes

Using cd instead of WORKDIR

FROM alpine:3.20
RUN cd /app
RUN pwd

The second RUN does not run in /app. Each RUN starts a new shell with the image metadata from the previous instruction. Use WORKDIR instead:

FROM alpine:3.20
WORKDIR /app
RUN pwd

Baking secrets into ENV or ARG

FROM alpine:3.20
ENV API_KEY=hardcoded-secret
RUN echo "configured" > /status.txt

This is wrong because ENV values are stored in image metadata, and build history can be inspected. ARG is also not a secret mechanism; values may appear in build logs or cache metadata depending on how they are used. Pass secrets at runtime through your orchestrator, Docker secrets, or mounted secret files. For a local demo, use an obvious placeholder and override at container start:

docker run --rm -e "API_KEY=<YOUR_API_KEY>" myapp:1.0

Expecting ARG to exist at runtime

FROM alpine:3.20
ARG MODE=production
CMD ["sh", "-c", "echo MODE=$MODE"]

This usually prints an empty value because ARG is build-time only. If the container needs a default, copy the value into ENV:

FROM alpine:3.20
ARG MODE=production
ENV MODE=${MODE}
CMD ["sh", "-c", "echo MODE=$MODE"]

Best Practices

  • Set WORKDIR near the top of every application Dockerfile so later relative paths are predictable.
  • Prefer absolute paths for the first WORKDIR, such as /app or /srv/app.
  • Use ENV for harmless runtime defaults, not passwords, tokens, private keys, or database credentials.
  • Use ARG for build-time choices such as version numbers, optional package selection, or base image variants.
  • Pin base image tags, such as node:20-alpine or alpine:3.20, because latest is a moving target and hurts reproducibility.
  • Keep dependency installation before source-code copies when possible so Docker can reuse cached layers.
  • Remember that changing an ENV, ARG use, or WORKDIR can affect every instruction after it.
  • Document expected runtime variables in your README or Compose file instead of hiding required configuration deep in application startup code.

Practice Exercises

  1. Create a Dockerfile for a small Python app using python:3.12-slim. Set WORKDIR /app, copy a requirements file first, install dependencies, then copy the app source. Hint: put the slow dependency step before the frequently changing source copy.
  2. Build an image that accepts ARG APP_VERSION=dev and exposes it as ENV APP_VERSION=${APP_VERSION}. Run a container and print the value with sh -c.
  3. Take an existing Dockerfile that uses RUN cd ... && ... several times. Refactor it to use one or more WORKDIR instructions and verify that the same files are created in the same directories.

Summary

  • WORKDIR sets the directory for later instructions and for the default container process.
  • ENV creates persisted environment defaults available during later build steps and at runtime.
  • ARG accepts build-time values and does not automatically exist in running containers.
  • Do not store secrets in ENV, ARG, or image layers.
  • Instruction order matters because Docker caches layers and invalidates every layer after a changed instruction.