Dockerfile WORKDIR, EXPOSE, USER and HEALTHCHECK
The instructions that finish a production Dockerfile. Why EXPOSE publishes nothing, why VOLUME is usually a mistake, and why Kubernetes ignores HEALTHCHECK.
These are the instructions that turn a Dockerfile that builds into one you would put in production. Two of them do considerably less than their names suggest, which is where most of the confusion lives.
WORKDIR
WORKDIR sets the directory for every RUN, COPY, ADD, CMD and ENTRYPOINT that follows, and it is where you land when you docker exec into the container.
WORKDIR /app
COPY package.json . # → /app/package.json
RUN npm ci # runs in /app
CMD ["node", "server.js"] # runs /app/server.js
It creates the directory if it does not exist, so RUN mkdir -p /app before it is redundant.
Never use RUN cd
RUN cd /app && npm ci # the cd dies with the shell
WORKDIR /app # persists for every later instruction
RUN npm ci
Each RUN starts a fresh shell, so a cd inside one has no effect on the next. This is a common source of files landing in / when the author was certain they were in /app.
Always use an absolute path
WORKDIR /app # unambiguous
WORKDIR app # relative to whatever WORKDIR came before
Relative WORKDIR stacks, which works until someone inserts a WORKDIR above it.
The permission gotcha
WORKDIR creates directories owned by root, even when a USER instruction came earlier:
USER node
WORKDIR /app/cache # created as root, and node cannot write to it
Error: EACCES: permission denied, mkdir '/app/cache/tmp'
Create and chown the directory before dropping privileges:
RUN mkdir -p /app/cache && chown node:node /app/cache
USER node
WORKDIR /app
EXPOSE publishes nothing
This is the single most misunderstood instruction in Docker.
EXPOSE 3000 # TCP by default
EXPOSE 8080/tcp
EXPOSE 53/udp
EXPOSE 80 443
EXPOSE is metadata. It opens no port, maps nothing, and changes no networking behaviour. A container with EXPOSE 3000 is not reachable from your host:
docker run myapp
curl localhost:3000
curl: (7) Failed to connect to localhost port 3000: Connection refused
Publishing is a runtime decision:
docker run -p 8080:3000 myapp # host 8080 → container 3000
Equally: a container without EXPOSE is still reachable by other containers on the same Docker network, and still publishable with -p. The instruction genuinely changes nothing about connectivity.
So why write it? Three real reasons: it documents the interface for whoever runs the image, docker run -P uses it to auto-publish every exposed port to a random host port, and Compose and other tooling read it when wiring services.
If a published port still refuses connections, the usual cause is the application binding to 127.0.0.1 inside the container rather than 0.0.0.0. Localhost inside a container is that container alone.
USER
Containers run as root by default. That is unnecessary for nearly every application and materially worse when combined with a mounted host path.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
Many official images already ship a non-root user: node, nginx, postgres. For your own:
# Alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Debian / Ubuntu
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser
Place USER after anything that needs root, and step back up briefly when you must:
USER root
RUN apt-get update && apt-get install -y --no-install-recommends tini
USER appuser
Use a numeric UID if you deploy to Kubernetes
USER 1000
Kubernetes' runAsNonRoot: true check runs before the image's user database is consulted, so a named user cannot be verified and the Pod fails to start:
Error: container has runAsNonRoot and image has non-numeric user (appuser),
cannot verify user is non-root
A numeric USER avoids it. USER 1000:1000 sets the group too.
LABEL
Key-value metadata, visible in docker inspect and filterable with docker images --filter.
LABEL org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.version="2.1.0" \
org.opencontainers.image.revision="$GIT_SHA" \
org.opencontainers.image.licenses="MIT"
Use the OCI standard keys rather than inventing your own. Tooling reads them, and org.opencontainers.image.source in particular is what makes GitHub link a published package back to its repository automatically.
Combine labels into one instruction; each LABEL is a layer.
docker inspect myapp --format='{{json .Config.Labels}}'
VOLUME is usually a mistake
VOLUME /app/data
This declares that a path should be backed by a volume, and Docker creates an anonymous volume for it whenever the container starts without an explicit -v.
That sounds helpful and mostly is not:
- Anonymous volumes accumulate. Every
docker runwithout-vleaves another one behind after the container is removed, quietly filling the disk until someone runsdocker volume prune. - The path becomes effectively frozen. Any
COPYorRUNthat writes to a declaredVOLUMEpath in a later instruction is discarded, because the volume is mounted over it at container start. Seeding a database image with data this way silently does nothing. - The decision is not the image's to make. Whoever runs the container knows whether they want a named volume, a bind mount or nothing at all.
Declare the path in your documentation and let docker run -v or a Compose volumes: entry do the work. VOLUME earns its place mainly in database images, where accidentally running without persistence is the worse failure.
HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
| Option | Default | Notes |
|---|---|---|
--interval | 30s | How often the check runs |
--timeout | 30s | Check is a failure if it exceeds this |
--start-period | 0s | Failures here do not count toward --retries |
--retries | 3 | Consecutive failures before unhealthy |
--start-period is the one that matters most. Without enough of it, a slow-starting application is marked unhealthy while it is still booting.
docker ps
CONTAINER ID IMAGE STATUS
9f2c1a4b7e3d myapp:1.0 Up 2 minutes (healthy)
Kubernetes ignores this entirely
Worth being blunt about, because it is widely assumed otherwise: Kubernetes does not read a Dockerfile's HEALTHCHECK. It uses its own livenessProbe, readinessProbe and startupProbe, and an image's healthcheck has no effect on scheduling, restarts or traffic routing.
HEALTHCHECK is useful for plain docker run, Docker Compose (depends_on with condition: service_healthy) and Docker Swarm. On Kubernetes, define probes in the Pod spec instead:
readinessProbe:
httpGet: { path: /health, port: 3000 }
periodSeconds: 5
Set WORKDIR, EXPOSE, and USER
You are completing a Dockerfile based on node:20-alpine. After installing dependencies and copying the source (owned by node:node), you need to: document that the app listens on port 3000, switch to the non-root "node" user, and start the app with "node server.js" using exec form. Which set of instructions, in order, is correct?
Common mistakes
| Mistake | Consequence |
|---|---|
RUN cd /app | Has no effect on later instructions |
WORKDIR after USER | Directory owned by root; app cannot write |
Expecting EXPOSE to publish | Port unreachable; needs -p |
App binds 127.0.0.1 | -p maps correctly and still refuses |
Named USER on Kubernetes | runAsNonRoot cannot verify it; Pod fails |
VOLUME then COPY to that path | Copied data silently discarded |
VOLUME in an app image | Anonymous volumes pile up on the host |
Relying on HEALTHCHECK in Kubernetes | Ignored; define probes instead |
No --start-period | Slow starters marked unhealthy while booting |
Quick reference
| Task | Instruction |
|---|---|
| Set working directory | WORKDIR /app |
| Document a port | EXPOSE 3000 |
| Publish a port | docker run -p 8080:3000 |
| Publish all exposed | docker run -P |
| Drop privileges | USER 1000:1000 |
| Standard metadata | LABEL org.opencontainers.image.source="…" |
| Health check | HEALTHCHECK --start-period=40s CMD … |
| Read health status | docker inspect -f '{{.State.Health.Status}}' <c> |
Key takeaways
WORKDIRreplacesRUN cd, creates the directory, and does so as root even afterUSER.EXPOSEpublishes nothing. It is documentation plus a hint for-Pand Compose.- A refused connection on a published port is usually the app bound to
127.0.0.1rather than0.0.0.0. - Set
USER, and make it numeric if the image will run under Kubernetes'runAsNonRoot. VOLUMEis usually the wrong call: anonymous volumes accumulate, and later writes to that path vanish.- Kubernetes ignores
HEALTHCHECK. UselivenessProbeandreadinessProbe. - Use OCI
LABELkeys, and combine them into one instruction.
Check your understanding
Seed data that disappears
A Dockerfile declares VOLUME /app/data and then, on a later line, COPY seed.json /app/data/. The build succeeds, but /app/data is empty in every container. Why?
Frequently Asked Questions
Does EXPOSE actually open a port in Docker?
No. EXPOSE records which port the containerised application listens on, for people reading the Dockerfile and for tooling. Nothing more. It opens nothing, maps nothing, and a container without it is just as reachable. To reach the application from your host you need -p 8080:3000 at docker run, and containers on the same Docker network can reach each other regardless. The one functional use is docker run -P, which publishes every exposed port to a random host port.
Why can't I reach my container even though I used -p?
Almost always because the application is bound to 127.0.0.1 inside the container. Localhost inside a container refers to that container alone, so Docker's port mapping delivers traffic to an interface nothing is listening on. Configure the server to bind 0.0.0.0, in Express that is app.listen(3000, '0.0.0.0'), in Flask --host=0.0.0.0. Confirm what is actually listening with docker exec <container> netstat -tlnp before changing anything else.
Why does my container get "permission denied" after I added USER?
Usually because a directory was created after the USER instruction. WORKDIR creates directories owned by root regardless of the current user, so USER node followed by WORKDIR /app/cache leaves the application unable to write to its own cache. Create and chown the directory while still root, then drop privileges. The other common cause is copied files still owned by root, which COPY --chown=node:node fixes in the same layer.
Should I use VOLUME in my Dockerfile?
Usually not, for application images. It creates an anonymous volume on every run that omits -v, and those pile up on the host until someone prunes them. Worse, any COPY or RUN writing to that path after the declaration is masked at runtime by the mount, so seed data silently disappears. Let whoever runs the container decide with -v or a Compose volumes: entry. It is defensible in database images, where the greater risk is someone running without persistence at all.
Does Kubernetes use the Dockerfile HEALTHCHECK?
No, and this trips up a lot of teams. Kubernetes ignores the image's HEALTHCHECK completely and relies on livenessProbe, readinessProbe and startupProbe defined in the Pod spec. An image with a perfect healthcheck gets no scheduling, restart or traffic-routing benefit on Kubernetes. HEALTHCHECK remains useful for plain docker run, for Docker Swarm, and for Compose's depends_on with condition: service_healthy.
What is the difference between WORKDIR and RUN cd?
RUN cd /app changes directory inside the shell that runs that one instruction, and that shell exits immediately afterwards, so the next instruction starts back at the previous directory. WORKDIR /app sets the directory for every subsequent instruction, persists into the final image as the container's starting directory, and creates the path if it does not exist. Use WORKDIR; the only place cd makes sense is chained within a single RUN, as in RUN cd /src && make install.