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:

  1. Documents the interface - tells users and tools what port to map
  2. Used by docker run -P (capital P) to auto-publish all exposed ports to random host ports
  3. 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:

  • node image → node user
  • nginx image → nginx user
  • postgres image → postgres user

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.

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?

Summary

  • WORKDIR sets the working directory for all subsequent instructions - use absolute paths, never RUN cd
  • EXPOSE documents ports but doesn't publish them - you still need -p at runtime
  • LABEL adds searchable metadata - use OCI standard labels for tooling compatibility
  • USER switches to a non-root user - always do this before CMD in production images
  • VOLUME declares mount points - Docker auto-creates anonymous volumes for them if none are provided
  • HEALTHCHECK lets orchestrators know if your container is actually functioning, not just running

Continue Learning

Explore Related Topics

Try the Tool

Related Resources