Dockerfile WORKDIR, EXPOSE, USER and HEALTHCHECK

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

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 run without -v leaves another one behind after the container is removed, quietly filling the disk until someone runs docker volume prune.
  • The path becomes effectively frozen. Any COPY or RUN that writes to a declared VOLUME path 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
OptionDefaultNotes
--interval30sHow often the check runs
--timeout30sCheck is a failure if it exceeds this
--start-period0sFailures here do not count toward --retries
--retries3Consecutive 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
Exercise

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

MistakeConsequence
RUN cd /appHas no effect on later instructions
WORKDIR after USERDirectory owned by root; app cannot write
Expecting EXPOSE to publishPort unreachable; needs -p
App binds 127.0.0.1-p maps correctly and still refuses
Named USER on KubernetesrunAsNonRoot cannot verify it; Pod fails
VOLUME then COPY to that pathCopied data silently discarded
VOLUME in an app imageAnonymous volumes pile up on the host
Relying on HEALTHCHECK in KubernetesIgnored; define probes instead
No --start-periodSlow starters marked unhealthy while booting

Quick reference

TaskInstruction
Set working directoryWORKDIR /app
Document a portEXPOSE 3000
Publish a portdocker run -p 8080:3000
Publish all exposeddocker run -P
Drop privilegesUSER 1000:1000
Standard metadataLABEL org.opencontainers.image.source="…"
Health checkHEALTHCHECK --start-period=40s CMD …
Read health statusdocker inspect -f '{{.State.Health.Status}}' <c>

Key takeaways

  • WORKDIR replaces RUN cd, creates the directory, and does so as root even after USER.
  • EXPOSE publishes nothing. It is documentation plus a hint for -P and Compose.
  • A refused connection on a published port is usually the app bound to 127.0.0.1 rather than 0.0.0.0.
  • Set USER, and make it numeric if the image will run under Kubernetes' runAsNonRoot.
  • VOLUME is usually the wrong call: anonymous volumes accumulate, and later writes to that path vanish.
  • Kubernetes ignores HEALTHCHECK. Use livenessProbe and readinessProbe.
  • Use OCI LABEL keys, and combine them into one instruction.

Check your understanding

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources