WORKDIR, USER, EXPOSE, LABEL, and Metadata
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.
root.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.
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.
- Using
RUN cd /app && python ...and expecting the directory to stick for the next instruction — eachRUNis a new shell, so thecdis lost;WORKDIR /appis 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-rootUSER. - Believing
EXPOSE 8000publishes the port or makes the service reachable — it is metadata only; withoutdocker run -p/-Pnothing is published and the service is unreachable from the host. COPY-ing files as root and then switching to a non-rootUSERthat can no longer write them (and cannot read them either if their mode is restrictive) — ownership has to be handled withCOPY --chownor achownin the same layer, or the app fails on its own files.
- Set
WORKDIRto an absolute application directory instead of chainingcdinRUN, so every later instruction runs in a predictable, persistent location. - Create and switch to a non-root
USERfor the container's main process so an app compromise does not start as root, pairing it withCOPY --chownfor file ownership. - Use
EXPOSEto document the listening port for humans and-P, while publishing deliberately withdocker 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, makingdocker inspectan audit tool.
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
RUNis a new shell, so acdis lost, whileWORKDIRsets the directory for all later instructions - A
RUN cdfails outright at build time, so the working directory is never actually changed in the first place WORKDIRexports a$PWDenvironment variable that eachRUNreads on startupRUN cdchanges a separate filesystem that is discarded, whileWORKDIRchanges 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 --chownor the app cannot write them post-switch - It makes the container start up faster; the gotcha is that
WORKDIRmust 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
rootcan no longer decrypt them after theUSERswitch
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