WORKDIR & EXPOSE
Set working directories cleanly and document ports correctly. Plus LABEL, USER, VOLUME, and HEALTHCHECK - the supporting instructions every production Dockerfile needs.
WORKDIR & EXPOSE
WORKDIR and EXPOSE are the two most frequently used supporting instructions. This page also covers LABEL, USER, VOLUME, and HEALTHCHECK - the smaller instructions that complete a production-grade Dockerfile.
WORKDIR - Set the Working Directory
WORKDIR sets the current directory for all subsequent RUN, COPY, ADD, CMD, and ENTRYPOINT instructions. It also becomes the default directory when you docker exec into a container.
WORKDIR /app
# All subsequent instructions are relative to /app
COPY package.json . # → /app/package.json
RUN npm install # runs in /app
CMD ["node", "server.js"] # runs /app/node.js
Creates the directory if it doesn't exist - no need for RUN mkdir -p /app.
Use absolute paths
# Absolute path - always clear
WORKDIR /app
# Relative path - confusing, depends on previous WORKDIR
WORKDIR app
Multiple WORKDIRs are fine
WORKDIR /app
COPY package*.json ./
RUN npm install
WORKDIR /app/config # Now in /app/config
COPY ./config .
WORKDIR /app # Back to /app
CMD ["node", "server.js"]
Each WORKDIR stacks - but for clarity, minimize context-switching and prefer staying in one directory.
Don't use RUN cd
# This cd only applies within that one RUN command
RUN cd /app && npm install
# WORKDIR persists across instructions
WORKDIR /app
RUN npm install
EXPOSE - Document Ports
EXPOSE tells Docker (and humans reading the Dockerfile) which network ports the container listens on. It is documentation only - it does not actually publish the port or make it reachable from the host.
EXPOSE 3000 # TCP (default)
EXPOSE 8080/tcp # Explicit TCP
EXPOSE 53/udp # UDP port
EXPOSE 80 443 # Multiple ports
To actually publish a port to the host, use -p at docker run time:
# EXPOSE 3000 in Dockerfile + this flag = host can reach it
docker run -p 8080:3000 myapp
# EXPOSE alone does nothing for host access
docker run myapp # Port 3000 is NOT reachable from host
So why use EXPOSE? Three reasons:
- Documents the interface - tells users and tools what port to map
- Used by
docker run -P(capital P) to auto-publish all exposed ports to random host ports - Used by Docker Compose and orchestration tools to wire services together
LABEL - Image Metadata
LABEL adds key-value metadata to an image. It's searchable and visible in docker inspect.
LABEL maintainer="platform-team@example.com"
LABEL version="2.1.0"
LABEL description="Production API server"
# OCI standard labels (recommended)
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="2.1.0"
LABEL org.opencontainers.image.created="2026-02-04T10:00:00Z"
LABEL org.opencontainers.image.authors="platform-team@example.com"
LABEL org.opencontainers.image.licenses="MIT"
Using the OCI Image Spec labels makes your images compatible with tooling that reads standard metadata.
Inspect labels:
docker inspect myapp --format='{{json .Config.Labels}}' | python3 -m json.tool
USER - Run as Non-Root
By default, containers run as root. This is a security risk - if an attacker escapes the container, they have root on the host. Always switch to a non-root user before the final CMD.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
# Switch to the built-in 'node' user (UID 1000)
USER node
CMD ["node", "server.js"]
Many official images already include a non-root user:
nodeimage →nodeusernginximage →nginxuserpostgresimage →postgresuser
For custom images, create the user explicitly:
# 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
You can temporarily step back to root during the build:
USER root
RUN apt-get install -y something
USER appuser
VOLUME - Declare Mount Points
VOLUME declares that a directory should be externally mountable - it marks a path as a persistence point.
VOLUME /app/data
VOLUME /var/log/myapp
VOLUME ["/app/data", "/app/logs"] # Multiple at once
When Docker creates a container from an image with VOLUME, it automatically creates an anonymous volume for each declared path if no volume is explicitly provided at docker run time.
# Docker auto-creates an anonymous volume for /app/data
docker run myapp
# Or provide a named volume explicitly
docker run -v mydata:/app/data myapp
Important: VOLUME only matters for data that must survive the container lifecycle. Don't use it for configuration or static files - those should be in the image layers.
HEALTHCHECK - Container Health Status
HEALTHCHECK defines a command Docker runs periodically to check if the container is healthy. It changes the container's status between starting, healthy, and unhealthy.
# HTTP health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# TCP check (nc)
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD nc -z localhost 5432 || exit 1
# Custom script
HEALTHCHECK CMD /app/scripts/healthcheck.sh
Options:
--interval- How often to run (default: 30s)--timeout- Max time for the check to complete (default: 30s)--start-period- Grace period before failures count (default: 0s)--retries- Failures before marking unhealthy (default: 3)
Check health status:
docker ps # STATUS column shows (healthy) or (unhealthy)
docker inspect --format='{{.State.Health.Status}}' myapp
Orchestrators like Docker Swarm and Kubernetes use health checks to decide whether to restart a container or route traffic to it.
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?
Summary
WORKDIRsets the working directory for all subsequent instructions - use absolute paths, neverRUN cdEXPOSEdocuments ports but doesn't publish them - you still need-pat runtimeLABELadds searchable metadata - use OCI standard labels for tooling compatibilityUSERswitches to a non-root user - always do this beforeCMDin production imagesVOLUMEdeclares mount points - Docker auto-creates anonymous volumes for them if none are providedHEALTHCHECKlets orchestrators know if your container is actually functioning, not just running