~/blog/-blog-docker-for-developers-
blog · DevOps

Docker for Developers: Containers, Images, and the Commands You Actually Use

What Docker containers actually are, how images and layers work, the Docker commands that matter in daily development, and how to write a Dockerfile that does not waste build time.

last updated · June 14, 2026by @vultio

Containers vs virtual machines: the distinction that matters

A virtual machine emulates a complete computer, including a full operating system kernel. A container shares the host OS kernel and only isolates the filesystem, process tree, network interfaces, and a few other namespaces. The practical result: containers start in milliseconds instead of minutes, use megabytes of memory instead of gigabytes, and can run hundreds of instances on a machine that might host a handful of VMs.

Docker is not the only container runtime, but it standardised the developer experience for building and running containers to the point where "Docker" is often used interchangeably with "containers." The Open Container Initiative (OCI) now maintains the image and runtime specs, so Docker images run on Kubernetes, Podman, and other runtimes without changes.

Images and layers: how Docker avoids redundant downloads

A Docker image is a read-only stack of filesystem layers. Each instruction in a Dockerfile (FROM, RUN, COPY, etc.) creates a new layer. When you pull an image, Docker downloads only the layers you do not already have — because layers are shared across images. A Node.js application image and a Python image both built onubuntu:22.04 share the Ubuntu layer; Docker stores it once.

# Each line in a Dockerfile = one layer
FROM node:20-alpine          # base layer (Alpine Linux + Node 20)
WORKDIR /app                 # creates /app directory in the layer
COPY package*.json ./        # new layer: only package files
RUN npm ci                   # new layer: node_modules installed
COPY . .                     # new layer: application source
EXPOSE 3000
CMD ["node", "server.js"]

# Why COPY package*.json before COPY . . matters:
# Docker caches layers. If package.json hasn't changed,
# the npm ci layer is reused from cache — no re-download.
# Without this split, every code change invalidates npm ci.

The commands that matter daily

# Build an image from Dockerfile in current directory
docker build -t myapp:latest .
docker build -t myapp:1.0 --no-cache .  # ignore cache, rebuild all layers

# Run a container
docker run myapp:latest
docker run -p 3000:3000 myapp:latest     # map host:container port
docker run -d myapp:latest               # detached (background)
docker run -it myapp:latest sh           # interactive shell
docker run --rm myapp:latest             # auto-remove container when done
docker run -e DATABASE_URL=postgres://... myapp:latest  # inject env var

# List running containers
docker ps
docker ps -a                             # all, including stopped

# Logs
docker logs <container-id>
docker logs -f <container-id>            # follow (tail -f equivalent)
docker logs --tail 100 <container-id>    # last 100 lines

# Execute a command in a running container
docker exec -it <container-id> sh
docker exec <container-id> env           # print env vars

# Stop and remove
docker stop <container-id>
docker rm <container-id>
docker rm -f <container-id>              # force-stop and remove

# Images
docker images                            # list local images
docker rmi myapp:latest                  # remove image
docker pull node:20-alpine               # pull without running

Docker Compose: running multi-container applications

Most applications need more than one container — an API server, a database, a cache, a background worker. Docker Compose defines all of these in a singledocker-compose.yml file and manages them as a unit.

# docker-compose.yml
services:
  api:
    build: .                             # build from local Dockerfile
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy       # wait for db to be ready
    volumes:
      - .:/app                           # mount source for hot reload
      - /app/node_modules                # but keep container's node_modules

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data  # persist data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine

volumes:
  postgres-data:

# Commands
docker compose up              # start everything, foreground
docker compose up -d           # start in background
docker compose down            # stop and remove containers
docker compose down -v         # also remove volumes (wipe DB)
docker compose logs -f api     # follow logs for the api service
docker compose exec api sh     # shell into running api container
docker compose restart api     # restart one service

Writing efficient Dockerfiles

Slow builds and large images are the two most common Docker pain points. Both are avoidable with the right Dockerfile structure.

Order layers from least to most frequently changing. Base image first, then package manifests and dependency installation, then application source last. A code change only invalidates the last layers; dependency installation is cached.

Use multi-stage builds to keep production images small. A build stage can have compilers, dev tools, and full build dependencies. The final stage copies only the compiled artifacts, leaving build tools out of the production image.

# Multi-stage build for a Node.js app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build            # compile TypeScript, bundle, etc.

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev        # production deps only
COPY --from=builder /app/dist ./dist   # only compiled output

USER node                    # never run as root
EXPOSE 3000
CMD ["node", "dist/server.js"]

# Result: production image has no TypeScript compiler,
# no devDependencies, no source files — typically 60-80% smaller

Use a .dockerignore file. Like .gitignore, it prevents unnecessary files from being sent to the Docker build context — which speeds up builds and prevents accidentally including secrets or large test fixtures in the image.

# .dockerignore
node_modules       # container installs its own
.git               # not needed in image
.env               # never include secrets
*.log
dist               # will be rebuilt inside container
coverage
.nyc_output
README.md

Inspecting and debugging containers

# Inspect container details (ports, env, network, mounts)
docker inspect <container-id>

# Check resource usage
docker stats                   # live resource usage for all containers
docker stats <container-id>    # single container

# Diff: what changed from the base image
docker diff <container-id>

# Check image layers and their sizes
docker history myapp:latest

# Clean up unused resources (reclaim disk space)
docker system prune            # unused containers, networks, dangling images
docker system prune -a         # also remove unused images (not just dangling)
docker system prune --volumes  # also remove unused volumes (removes data!)

# Check disk usage
docker system df