WierdX — Programming Reference All tutorials →
Developer reference · Practical tutorials · CS fundamentals
DevOps & Infrastructure

Docker for Developers: Containers, Images, and Practical Workflows

Docker packages your application and its entire runtime environment into a portable unit that runs identically on any machine. This guide explains the core concepts and walks through a complete workflow from Dockerfile to multi-container compose setup.

Published June 30, 2026

Containers vs Virtual Machines

A virtual machine emulates a full hardware stack and runs its own guest OS kernel. A container, by contrast, shares the host machine's kernel. What a container isolates are the user-space processes, filesystems, network interfaces, and resource limits — using Linux kernel features called namespaces and cgroups. The result is that containers start in milliseconds instead of minutes, consume tens of megabytes of RAM instead of gigabytes, and have almost zero overhead compared to running a process natively.

The trade-off: containers are less isolated than VMs. A kernel vulnerability can potentially affect all containers on the same host, whereas a VM guest is insulated by a hypervisor. For most development and application-hosting scenarios, the isolation containers provide is more than sufficient. For highly sensitive multi-tenant workloads, VMs (or specialized runtimes like gVisor) offer stronger boundaries.

Images and Layers

A Docker image is a read-only, layered filesystem. Each instruction in a Dockerfile produces one layer. Layers are cached independently: if you only change your application code, Docker can reuse the cached layers for your OS base image and installed dependencies, rebuilding only the changed layer. This is why the order of Dockerfile instructions matters — put infrequently changing steps early, frequently changing steps (like copying source code) late.

When you run a container, Docker adds a thin writable layer on top of the read-only image layers. All writes during the container's lifetime go into this writable layer. When the container is deleted, that layer is discarded. This means containers are ephemeral by default, which is a feature: it enforces immutability in your infrastructure. Persistent state belongs in volumes, not in container layers.

A Real Dockerfile: Node.js Application

# Use the minimal Alpine-based Node image to keep size down
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package files first (layer cache optimization)
COPY package.json package-lock.json ./

# Install dependencies (cached as long as package.json doesn't change)
RUN npm ci --omit=dev

# Copy the rest of the application source
COPY . .

# Document which port the app listens on (informational only)
EXPOSE 3000

# The command to run when the container starts
CMD ["node", "src/index.js"]

The WORKDIR instruction creates the directory if it does not exist and sets it as the working directory for all subsequent instructions. RUN npm ci --omit=dev installs only production dependencies; development tools do not belong in a production image. EXPOSE is documentation — it does not actually open a port. Port mapping happens at runtime with docker run.

Building and Running

# Build the image, tagging it with a name and version
docker build -t myapp:1.0 .

# Run the container, mapping host port 8080 to container port 3000
docker run -p 8080:3000 myapp:1.0

# Run in detached mode (background)
docker run -d -p 8080:3000 --name myapp-container myapp:1.0

# Stream logs from a running container
docker logs -f myapp-container

# Open a shell inside a running container for debugging
docker exec -it myapp-container sh

# Stop and remove
docker stop myapp-container
docker rm myapp-container

The -p host:container flag binds a port on your host to a port inside the container. Traffic arriving at localhost:8080 on your machine is forwarded to port 3000 inside the container. You can map multiple ports with multiple -p flags. If you want to bind only on a specific interface, use -p 127.0.0.1:8080:3000.

Volumes for Data Persistence

Because container filesystems are ephemeral, any data written inside a container is lost when the container is removed. Volumes solve this by mounting a directory from the host (or a Docker-managed volume) into the container's filesystem.

# Mount a named volume (Docker manages the storage location)
docker run -d -v postgres_data:/var/lib/postgresql/data postgres:16

# Mount a host directory as a bind mount (useful for development)
docker run -d -v $(pwd)/data:/app/data myapp:1.0

# List existing volumes
docker volume ls

# Remove unused volumes
docker volume prune

Named volumes are preferred in production because Docker manages their lifecycle independently of containers. Bind mounts are convenient in development because changes on the host are immediately visible inside the container without rebuilding the image — a pattern used in hot-reload development servers. This is relevant to the broader principle of separating environment variables and configuration from application code.

docker-compose for Multi-Container Apps

Real applications involve multiple processes: a web server, a database, a cache, a queue. Running and wiring these manually with docker run quickly becomes unmanageable. Docker Compose defines all services declaratively in a single YAML file.

services:
  web:
    build: .
    ports:
      - "8080:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./src:/app/src

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  postgres_data:
# Start all services (build if needed)
docker compose up --build

# Start detached
docker compose up -d

# Tear down, keeping volumes
docker compose down

# Tear down and delete volumes (destructive)
docker compose down -v

Common Gotchas

  • Image size bloat. A naive FROM node:20 image starts at 1 GB before you add anything. Use node:20-alpine (around 130 MB). For even smaller images, use multi-stage builds: compile in a full image, copy only the compiled output into an alpine final stage.
  • Container exit codes. Exit code 0 is clean. Exit code 1 is a general error — check logs. Exit code 137 means the container was killed (often OOM); increase Docker's memory limit. Exit code 143 is SIGTERM; your app should handle it gracefully for zero-downtime deploys.
  • Credentials in images. Never bake secrets into your Dockerfile with ENV instructions. They appear in docker inspect output and in the layer history. Pass secrets at runtime via --env-file or Docker secrets. This ties directly to environment variable best practices.
  • Not using .dockerignore. Without a .dockerignore file, COPY . . sends your entire build context to the Docker daemon, including node_modules, .git, and any local secrets files. Create a .dockerignore modeled on your .gitignore.