Docker Demystified: Containers, Images, Volumes, and Networks Explained

Why Containers Changed Everything

Remember the days of "but it works on my machine"? You'd spend hours configuring dependencies, libraries, and environment variables only to have your app crash in production. Different OS versions, missing packages, conflicting Python or Node versions—deployment was a nightmare. Docker solved this by packaging your application with everything it needs to run, guaranteed to work the same way everywhere.

The Box That Travels Anywhere

Think of it like this: Shipping containers revolutionized global trade by standardizing how goods are transported. A container fits on any ship, train, or truck—no repacking needed. Docker does the same for software. Your app, its dependencies, and configuration are packaged once and run anywhere—your laptop, staging server, or production cloud.
Before containers, you shipped code and hoped the server had the right environment. With Docker, you ship the entire environment.

Docker Images: The Blueprint

A Docker image is a read only template containing your application code, runtime, libraries, and dependencies. Think of it as a snapshot or a class in programming—it defines what the container will be.
Images are built in layers. When you create a Dockerfile, each instruction (like RUN, COPY, ADD) creates a new layer. Docker caches these layers, so if you change just one line in your code, only affected layers rebuild—making builds incredibly fast.
Why layers matter: If your base image (like node:18) is 900MB and your app code is 10MB, you don't download 910MB every time. Docker reuses the base layer. Only the changed 10MB gets transferred.

Anatomy of a Dockerfile: Line by Line Breakdown

Before we dive into multistage builds, let's understand what each line in a Dockerfile actually does. Here's a complete example for a Node.js application:
dockerfile
FROM node:18-alpine LABEL maintainer="team@example.com" ENV NODE_ENV=production \ PORT=3000 WORKDIR /app RUN apk add --no-cache curl COPY package*.json ./ RUN npm ci --only=production COPY . . RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 && \ chown -R nodejs:nodejs /app USER nodejs EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -f http://localhost:3000/health || exit 1 CMD ["node", "server.js"]

1. Foundation & Metadata

InstructionExplanation
FROM node:18-alpineThis is the base image your container builds upon. Think of it as the foundation. The node:18-alpine image contains Node.js version 18 on Alpine Linux, a minimal distribution. Alpine is only 5MB compared to Ubuntu which is 70MB+. Every Dockerfile must start with FROM.
LABEL maintainer=...Labels are metadata attached to your image. They don't affect how the container runs but help with organization. You can add labels for version numbers, descriptions, or contact information. Think of them as tags on a file folder.

2. Environment & Workspace

InstructionExplanation
ENV NODE_ENV=...ENV sets environment variables that will be available inside the container. These variables persist when the container runs, unlike ARG which only exists during build. Applications can read these values. Here we're telling Node.js to run in production mode and setting the default port.
WORKDIR /appThis sets the working directory for all subsequent instructions. It's like doing cd /app in your terminal. If the directory doesn't exist, Docker creates it. All relative paths in COPY, RUN, and CMD will be relative to this directory.

3. Build Dependencies & Logic

InstructionExplanation
RUN apk add ...RUN executes commands during image build. Each RUN creates a new layer. Here we're installing curl using Alpine's package manager (apk). The --no-cache flag prevents saving the package index, reducing image size. We need curl for the health check later.
COPY package*.json ./COPY transfers files from your host machine into the image. The package*.json pattern matches both package.json and package-lock.json. We copy these first (before copying all code) because Docker caches layers. If package.json hasn't changed, Docker skips the npm install step, making rebuilds faster.
RUN npm ci ...This installs dependencies. We use npm ci instead of npm install because it's faster and more reliable in automated environments. It installs exactly what's in package-lock.json. The --only=production flag skips development dependencies, shrinking the image.
COPY . .Now we copy the rest of the application code. This comes after dependency installation so changes to your code don't invalidate the cached dependency layer. The first dot means "current directory on host" and second dot means "current WORKDIR in container" which is /app.

4. Security & User Management

InstructionExplanation
RUN addgroup ...Security best practice: don't run containers as root. This creates a system group and user named nodejs with specific IDs, then changes ownership of /app to this user. We chain commands with && to create a single layer instead of three.
USER nodejsThis switches to the nodejs user we just created. All subsequent RUN, CMD, and ENTRYPOINT commands will execute as this user, not root. If an attacker compromises your container, they won't have root privileges.

5. Networking & Health

InstructionExplanation
EXPOSE 3000This documents which port the container listens on. It's purely informational and doesn't actually publish the port. You still need -p 3000:3000 when running the container. Think of it as a note saying "this app uses port 3000."
HEALTHCHECK ...This tells Docker how to test if your container is healthy. Every 30 seconds, Docker runs the curl command. If it fails or times out after 3 seconds, the container is marked unhealthy. Orchestration tools can automatically restart unhealthy containers.

6. Execution

InstructionExplanation
CMD ["node", "server.js"]This is the default command that runs when the container starts. Unlike RUN which executes during build, CMD executes when you docker run. The array syntax (exec form) is preferred over string syntax because it doesn't invoke a shell, making it more efficient and signals work properly.
Would you like me to create a comparison table for the differences between COPY vs ADD or CMD vs ENTRYPOINT?

Multistage Docker Builds: Leaner Images

Traditional Dockerfiles include build tools (compilers, npm, maven) in the final image, bloating its size. Multistage builds solve this by separating build and runtime environments.
Here's a Go application example:
dockerfile
# Stage 1: Build stage FROM golang:1.21-alpine AS builder # Why: Set working directory for build context WORKDIR /app # Why: Copy dependency files first for better caching COPY go.mod go.sum ./ RUN go mod download # Why: Copy source code and build the binary COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o main . # Stage 2: Runtime stage FROM alpine:latest # Why: Install CA certificates for HTTPS calls RUN apk --no-cache add ca-certificates WORKDIR /root/ # Why: Copy only the compiled binary from builder stage # This excludes source code, build tools, and intermediate files COPY --from=builder /app/main . EXPOSE 8080 CMD ["./main"]
The magic: The final image contains only the Alpine base (5MB) and your compiled binary (maybe 15MB). Total: ~20MB. Without multistage, you'd ship the full Go toolchain (300MB+).
Result: Faster deployments, lower bandwidth costs, smaller attack surface.

Docker Containers: Running Instances

If an image is a blueprint, a container is the actual running house. You can create multiple containers from one image—like running multiple instances of your web server.
Containers are isolated but lightweight. Unlike virtual machines (which virtualize hardware), containers share the host OS kernel but have isolated processes, filesystems, and networks. This makes them start in milliseconds instead of minutes.
Key characteristics:
  • Ephemeral: Containers should be disposable. Delete and recreate them without worry.
  • Immutable: Don't modify running containers. Change the image and redeploy.
  • Stateless: Store persistent data in volumes, not containers.

Docker Volumes: Persisting Your Data

Containers are ephemeral by design. When a container stops, its data vanishes. But databases, logs, and uploads need to persist. That's where volumes come in.
Volumes are storage managed by Docker, living outside the container filesystem. They survive container restarts, deletions, and updates.
Three types of data persistence:
  1. Named volumes (recommended): Docker manages storage location
    bash
    docker volume create my_data docker run -v my_data:/app/data myapp
  2. Bind mounts: Map host directory directly into container
    bash
    docker run -v /host/path:/container/path myapp
  3. tmpfs mounts: In memory storage, lost on container stop (useful for secrets)
Why volumes matter: Your database container can be destroyed and recreated while data remains safe in the volume. Upgrade Postgres from 14 to 15? Just swap the container, keep the volume.

Docker Networks: Container Communication

Containers need to talk to each other—your web app needs the database, the API needs Redis. Docker networks make this seamless and secure.
Network types:
  1. Bridge (default): Containers on the same host communicate via private network
  2. Host: Container uses host's network directly (no isolation, better performance)
  3. Overlay: Multi host networking for Docker Swarm/Kubernetes
  4. None: No networking, complete isolation
When you create a network, Docker provides built in DNS. Containers reference each other by name, not IP.
bash
docker network create my_network docker run --network=my_network --name=database postgres docker run --network=my_network --name=webapp myapp
Inside webapp, you connect to database:5432—Docker resolves it automatically. No hardcoded IPs. Restart the database container? New IP doesn't matter; the name stays the same.
Security win: Containers on different networks can't communicate unless explicitly connected. Your public facing web container can't directly access your internal admin database unless you allow it.

Complete Workflow Example

Here's how it all comes together:
dockerfile
# Multistage Dockerfile for Node.js app FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build FROM node:18-alpine WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules EXPOSE 3000 CMD ["node", "dist/server.js"]
Build and run:
bash
# Build the image docker build -t myapp:1.0 . # Create network and volume docker network create app_network docker volume create app_data # Run database docker run -d \ --name postgres \ --network app_network \ -v app_data:/var/lib/postgresql/data \ -e POSTGRES_PASSWORD=secret \ postgres:15 # Run application docker run -d \ --name webapp \ --network app_network \ -p 3000:3000 \ myapp:1.0
What happens:
  • App and database communicate via app_network
  • Database persists data in app_data volume
  • App accessible on localhost:3000
  • Rebuild myapp:1.0 anytime; data remains safe

When Docker Shines

Microservices: Each service runs in its own container CI/CD pipelines: Consistent build and test environments Local development: Match production environment exactly Scaling: Spin up multiple containers behind a load balancer Legacy apps: Containerize old apps without refactoring

Common Pitfalls to Avoid

Running as root: Always use non root users in containers for security
dockerfile
# WRONG: Runs as root by default FROM node:18 WORKDIR /app COPY . . CMD ["node", "server.js"] # RIGHT: Creates and uses non-root user FROM node:18 RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 USER nodejs WORKDIR /app COPY --chown=nodejs:nodejs . . CMD ["node", "server.js"]
Storing secrets in images: Never hardcode passwords or API keys
Ignoring .dockerignore: Prevents copying node_modules, .git, build artifacts into image
Mounting volumes in production improperly: Use named volumes, not bind mounts, for production data

Mastering the Fundamentals

You've learned how Docker images serve as templates, containers run your applications, volumes persist data, and networks enable communication. Multistage builds keep images lean and deployments fast.
The power of Docker isn't just isolation—it's consistency. Build once, run anywhere. No more "works on my machine." The same container that runs on your laptop runs identically in production.

Next Steps

Explore: Docker Compose for multi container applications Practice: Containerize your existing projects Build: Create a complete microservices app with Docker networks and volumes

Docker Commands Reference

Image Commands

bash
# List images docker images docker image ls # Pull image from registry docker pull nginx:latest docker pull ubuntu:22.04 # Build image from Dockerfile docker build -t myapp:1.0 . docker build -t myapp:latest --no-cache . # Tag image docker tag myapp:1.0 myapp:latest docker tag myapp:1.0 myregistry.com/myapp:1.0 # Push image to registry docker push myregistry.com/myapp:1.0 # Remove image docker rmi myapp:1.0 docker image rm myapp:1.0 # Remove unused images docker image prune docker image prune -a # Remove all unused images # View image history and layers docker history myapp:1.0 # Inspect image details docker inspect myapp:1.0 # Save image to tar file docker save -o myapp.tar myapp:1.0 # Load image from tar file docker load -i myapp.tar

Container Commands

bash
# Run container docker run nginx docker run -d nginx # Detached mode docker run -d -p 8080:80 nginx # Port mapping docker run -d --name webserver nginx # Named container docker run -it ubuntu bash # Interactive mode with terminal # List containers docker ps # Running containers only docker ps -a # All containers including stopped # Start/stop containers docker start container_name docker stop container_name docker restart container_name # Pause/unpause container docker pause container_name docker unpause container_name # Remove container docker rm container_name docker rm -f container_name # Force remove running container # Remove all stopped containers docker container prune # View container logs docker logs container_name docker logs -f container_name # Follow logs docker logs --tail 100 container_name # Last 100 lines # Execute command in running container docker exec -it container_name bash docker exec container_name ls /app # View container processes docker top container_name # Inspect container details docker inspect container_name # View container resource usage docker stats docker stats container_name # Copy files to/from container docker cp file.txt container_name:/path/ docker cp container_name:/path/file.txt ./ # Attach to running container docker attach container_name # Export container filesystem to tar docker export container_name > container.tar # Create image from container docker commit container_name myimage:1.0

Volume Commands

bash
# Create volume docker volume create my_volume # List volumes docker volume ls # Inspect volume details docker volume inspect my_volume # Remove volume docker volume rm my_volume # Remove all unused volumes docker volume prune # Mount volume to container docker run -v my_volume:/app/data nginx docker run --mount source=my_volume,target=/app/data nginx # Bind mount (host directory) docker run -v /host/path:/container/path nginx docker run -v $(pwd):/app nginx # Mount current directory # Read-only volume docker run -v my_volume:/app/data:ro nginx # List containers using specific volume docker ps -a --filter volume=my_volume

Network Commands

bash
# Create network docker network create my_network docker network create --driver bridge my_network docker network create --subnet=172.18.0.0/16 my_network # List networks docker network ls # Inspect network details docker network inspect my_network # Connect container to network docker network connect my_network container_name # Disconnect container from network docker network disconnect my_network container_name # Remove network docker network rm my_network # Remove all unused networks docker network prune # Run container with specific network docker run --network=my_network nginx # Run container with multiple networks docker run --network=network1 nginx docker network connect network2 container_name

System Commands

bash
# Show Docker disk usage docker system df # Remove all unused data (containers, images, networks, volumes) docker system prune docker system prune -a # Also remove unused images docker system prune -a --volumes # Include volumes # View system-wide information docker info # Show Docker version docker version

Multi-Container Management

bash
# Run multiple containers and link them docker run -d --name database postgres docker run -d --name webapp --link database:db myapp # Create and run containers with network docker network create app_net docker run -d --name db --network app_net postgres docker run -d --name web --network app_net -p 80:80 nginx

Image Registry Commands

bash
# Login to Docker registry docker login docker login myregistry.com # Logout from registry docker logout # Search for images in Docker Hub docker search nginx docker search --limit 5 nginx

Build Commands

bash
# Build with build arguments docker build --build-arg VERSION=1.0 -t myapp . # Build with specific Dockerfile docker build -f Dockerfile.prod -t myapp:prod . # Build with no cache docker build --no-cache -t myapp . # Build and tag multiple tags docker build -t myapp:1.0 -t myapp:latest . # Build with target stage (multistage) docker build --target builder -t myapp:build .
All Blogs
Tags:dockercontainersdevopsinfrastructuremicroservices