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 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.jsIt creates the directory if it does not exist, so RUN mkdir -p /app before it is redundant.
RUN cd /app && npm ci # the cd dies with the shell
WORKDIR /app # persists for every later instruction
RUN npm ciEach 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.
WORKDIR /app # unambiguous
WORKDIR app # relative to whatever WORKDIR came beforeRelative WORKDIR stacks, which works until someone inserts a WORKDIR above it.
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 itError: 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 /appThis is the single most misunderstood instruction in Docker.
EXPOSE 3000 # TCP by default
EXPOSE 8080/tcp
EXPOSE 53/udp
EXPOSE 80 443EXPOSE 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:3000curl: (7) Failed to connect to localhost port 3000: Connection refused
Publishing is a runtime decision:
docker run -p 8080:3000 myapp # host 8080 â container 3000Equally: 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.
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 appuserPlace 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 appuserUSER 1000Kubernetes' 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.
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 /app/dataThis 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:
docker run without -v leaves another one behind after the container is removed, quietly filling the disk until someone runs docker volume prune.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.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 --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 psCONTAINER ID IMAGE STATUS
9f2c1a4b7e3d myapp:1.0 Up 2 minutes (healthy)
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<MCQExercise exercise={{ id: 'workdir-expose-q1', title: 'Set WORKDIR, EXPOSE, and USER', description: '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?', options: [ { id: 'a', text: 'EXPOSE 3000\nUSER node\nCMD ["node", "server.js"]', }, { id: 'b', text: 'USER node\nEXPOSE 3000\nCMD node server.js', }, { id: 'c', text: 'PORT 3000\nUSER node\nCMD ["node", "server.js"]', }, { id: 'd', text: 'EXPOSE 3000\nUSER root\nCMD ["node", "server.js"]', }, ], correctId: 'a', explanation: '"EXPOSE 3000" documents that the container listens on port 3000 (a hint for users running the image, not an actual port mapping). "USER node" switches the container to run as the non-root "node" user for better security. "CMD [\"node\", \"server.js\"]" uses exec form, which runs the process directly as PID 1 without a shell wrapper, allowing it to receive signals like SIGTERM properly.', wrongExplanations: { b: '"CMD node server.js" uses shell form instead of exec form. In shell form, the command runs as a child of "/bin/sh -c", so the Node process is not PID 1 and may not receive shutdown signals (like SIGTERM) directly, making graceful shutdown harder.', c: '"PORT" is not a valid Dockerfile instruction. The correct instruction for documenting a listening port is "EXPOSE".', d: 'Setting "USER root" runs the container process as the root user, defeating the purpose of switching to a non-root user for security. The requirement was to switch to the "node" user specifically.', }, }} />
| 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 |
| 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> |
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.127.0.0.1 rather than 0.0.0.0.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.HEALTHCHECK. Use livenessProbe and readinessProbe.LABEL keys, and combine them into one instruction.<MCQExercise exercise={{ id: 'docker-volume-seed-lost', title: 'Seed data that disappears', description: '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?', options: [ { id: 'a', text: 'A volume is mounted over the declared path at container start, hiding anything written to it in later build instructions' }, { id: 'b', text: 'COPY cannot write to a directory that has been declared with VOLUME, so the build silently skips it' }, { id: 'c', text: 'Anonymous volumes are read-only until a named volume is attached with -v' }, { id: 'd', text: 'VOLUME must come after all COPY instructions or the Dockerfile fails to parse' }, ], correctId: 'a', explanation: 'Declaring VOLUME marks the path as externally backed. When the container starts, Docker mounts a volume there, an anonymous one if you did not supply your own, and that mount sits on top of whatever the image layers contain at that path, so the seed file is present in the image but invisible at runtime. Either drop the VOLUME declaration and let the operator decide with -v, or copy the seed elsewhere and have an entrypoint script populate the volume on first start, which is what official database images do.', wrongExplanations: { b: 'The COPY genuinely runs and the file really is in the image layer. It is masked at runtime by the mount, not skipped at build time.', c: 'Anonymous volumes are writable. Read-only behaviour would require mounting with the :ro option.', d: 'Instruction order does not cause a parse error. Placing VOLUME last avoids this specific surprise, but the Dockerfile is valid either way.', }, }} />
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.
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.
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.
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.
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.
Write safer, smaller Dockerfiles. Covers exec form for SIGTERM, non-root users, secrets, layer order, multi-stage builds, pinned bases, scanning and hadolint.
Copy production-ready Dockerfile examples for Node.js, Python FastAPI, Go, React with Nginx, Spring Boot and Django, and see the pattern they all share.
Learn why container data disappears and how Docker volumes fix it. Compare the three mount types, handle permission problems, and back up or clean up volumes.