Chapter 4: Dockerfiles
Topic 26

WORKDIR, USER, EXPOSE, LABEL, and Metadata

InstructionPosture

Beyond the instructions that build and run the app, a Dockerfile carries a handful that set the container's posture: where it runs, who it runs as, what it advertises, and what it declares about itself. WORKDIR sets the directory, USER sets the identity, EXPOSE documents a port, and LABEL attaches metadata.

None are exotic, but each has a sharp edge. USER root by default is a real risk, RUN cd silently does not persist, and EXPOSE does far less than people assume. None of these change what the app does; together they decide where it runs, with what privileges, and how traceable it is — which is most of the gap between a working image and a well-behaved one.

WORKDIR — The Working Directory

WORKDIR /app sets the directory for subsequent RUN, CMD, ENTRYPOINT, COPY, and ADD, creating it if it does not exist. It is the clean alternative to RUN cd /app && ..., which silently does not persist across instructions: each RUN is a fresh shell, so a cd in one is gone by the next. WORKDIR is the instruction that actually sticks.

USER — The Run-Time Identity

USER sets the user for subsequent instructions and for the container's main process. Containers run as root by default, so a RUN adduser app followed by USER app is how Driftwood's gunicorn stops running as root — shrinking the blast radius of an app compromise from "root inside the container" to "an unprivileged process." Full hardening comes in Chapter 10; this is the one-line start.

EXPOSE — Documentation, Not a Firewall

EXPOSE 8000 declares which port the container listens on, as metadata only. It does not publish the port or open anything — that is docker run -p 8000:8000. EXPOSE is a hint to humans reading the Dockerfile and to docker run -P, which uses it to pick ports to publish; on its own it changes nothing about reachability.

LABEL — Image Metadata

LABEL attaches key/value metadata — org.opencontainers.image.source, .revision, .version — readable with docker inspect. OCI-standard labels give an image provenance, tying it back to a repository and a commit, which is the supply-chain habit from Chapter 1's image-identity material written into the build. A labeled image answers "where did this come from?" without guesswork.

Four posture-setting instructions
WORKDIR
Sets the working directory for later instructions, creating it if needed.
USER
Drops privileges — runs the process as a non-root user instead of root.
EXPOSE
Documents a port as metadata — publishes nothing on its own.
LABEL
Attaches image metadata (provenance) readable with docker inspect.

The Posture They Set Together

WORKDIR /app, a non-root USER app, an honest EXPOSE 8000, and provenance LABELs turn a working image into a well-behaved one. None change what the app does, but together they decide where it runs, with what privileges, and how traceable it is.

Driftwood's posture — directory, identity, port, provenance
WORKDIR /app
RUN adduser --disabled-password app
COPY --chown=app:app . .
USER app
EXPOSE 8000
LABEL org.opencontainers.image.source="https://github.com/acme/driftwood" \
      org.opencontainers.image.revision="$GIT_SHA"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

The COPY --chown=app:app matters as much as the USER app: copy files as root and then switch users, and the app can no longer write to its own files — and, if their mode is restrictive, cannot even read them. Handling ownership in the same copy is what keeps the non-root switch from breaking the container on startup.

Common Mistakes
  • Using RUN cd /app && python ... and expecting the directory to stick for the next instruction — each RUN is a new shell, so the cd is lost; WORKDIR /app is the instruction that persists.
  • Leaving the container running as root (the default) in production — an app-level compromise then has root inside the container and a larger path to the host; set a non-root USER.
  • Believing EXPOSE 8000 publishes the port or makes the service reachable — it is metadata only; without docker run -p/-P nothing is published and the service is unreachable from the host.
  • COPY-ing files as root and then switching to a non-root USER that can no longer write them (and cannot read them either if their mode is restrictive) — ownership has to be handled with COPY --chown or a chown in the same layer, or the app fails on its own files.
Best Practices
  • Set WORKDIR to an absolute application directory instead of chaining cd in RUN, so every later instruction runs in a predictable, persistent location.
  • Create and switch to a non-root USER for the container's main process so an app compromise does not start as root, pairing it with COPY --chown for file ownership.
  • Use EXPOSE to document the listening port for humans and -P, while publishing deliberately with docker run -p — never mistake the declaration for the action.
  • Attach OCI provenance LABELs (source repo, revision, version) so every image traces back to a commit, making docker inspect an audit tool.
Comparable tools OCI image config stores WorkingDir, User, exposed ports, and labels identically, so Podman and containerd read the same metadata Kubernetes re-declares the working dir, user (securityContext.runAsUser), and ports in the pod spec rather than trusting the image Buildpacks set a non-root user and standard labels automatically

Knowledge Check

Why does WORKDIR persist where RUN cd does not?

  • Each RUN is a new shell, so a cd is lost, while WORKDIR sets the directory for all later instructions
  • A RUN cd fails outright at build time, so the working directory is never actually changed in the first place
  • WORKDIR exports a $PWD environment variable that each RUN reads on startup
  • RUN cd changes a separate filesystem that is discarded, while WORKDIR changes the real one

Why set a non-root USER, and what is the file-ownership gotcha?

  • It shrinks a compromise's blast radius; but files copied as root need COPY --chown or the app cannot write them post-switch
  • It makes the container start up faster; the gotcha is that WORKDIR must be set first or the user directive is silently ignored
  • Running as a non-root user is required before the container is allowed to bind a network port at all
  • It encrypts the app's files on disk; the gotcha is that root can no longer decrypt them after the USER switch

What does EXPOSE 8000 actually do?

  • It documents the listening port as metadata; publishing it still requires docker run -p
  • It publishes the port to the host automatically, making the service reachable with no further flags
  • It opens an iptables firewall rule that allows inbound traffic to that port from any remote host
  • It forces the application inside the container to bind and listen on that port

What do OCI LABELs buy for an image?

  • Provenance — source repo, revision, and version readable with docker inspect, tying the image to a commit
  • They automatically compress the image's underlying filesystem layers, shrinking the total size that is pushed to and later pulled back from a registry
  • They tell the daemon which host to schedule the container on at run time based on the values
  • They restrict which authenticated users are allowed to pull the image down from the registry

You got correct