Article
ELI5: Docker Containers Explained
Understanding containers, images, and why they matter for deployment.
What is Docker? Why Should You Care?
Imagine you’re shipping a cake. With traditional deployment, you’d send the recipe, ingredients, and hope the baker has the same oven, same flour quality, and same kitchen setup as you. It probably won’t taste the same.
Docker solves this by shipping the entire kitchen — the recipe, pre-measured ingredients, the exact oven settings, and everything else. When it arrives, you just turn it on. It works the same every single time.
That’s containerization in a nutshell.
Containers vs Virtual Machines: The Real Difference
People often confuse containers with VMs. Here’s the key difference:
Virtual Machine (VM):
- Runs a full operating system
- Needs 500 MB - 2 GB of RAM per instance
- Slow to start (takes minutes)
- Heavy isolation (good for untrusted code)
- Takes a lot of disk space
Container:
- Shares the host OS kernel
- Needs 10-50 MB of RAM typically
- Starts in milliseconds
- Lighter isolation (good for your own code)
- Minimal disk footprint
Think of it this way:
- VMs are like shipping a house (with walls, foundation, kitchen)
- Containers are like shipping a prefab living room (uses your house’s foundation, electricity, plumbing)
VMs:
┌─────────────────────┐
│ App │
│ Guest OS │
│ Hypervisor │
└─────────────────────┘
Heavy, isolated
Containers:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ App │ │ App │ │ App │
│ Libs │ │ Libs │ │ Libs │
└─────────┴─┴─────────┴─┴─────────┘
Shared Host OS Kernel
Lightweight, fast
Core Concepts: Images and Containers
Image: A blueprint or snapshot. Like a recipe. It’s read-only, and it describes everything needed to run your application.
Container: A running instance of an image. Like baking from the recipe. Many containers can run from the same image.
An analogy:
- Image = Class in programming
- Container = Instance of that class
You can have one ubuntu:22.04 image and run 100 different containers from it, each with its own isolated filesystem and processes.
Dockerfile: Building Your Image
A Dockerfile is the recipe. It contains instructions for what goes into your image.
Here’s a simple example for a Node.js app:
# Start from an official Node.js image
FROM node:18-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy your code into the container
COPY . .
# Install dependencies
RUN npm install
# Expose the port your app listens on
EXPOSE 3000
# The command to run when the container starts
CMD ["npm", "start"]
Let’s break this down:
- FROM: Base image (the starting point)
- WORKDIR: Directory where commands run (like
cd /app) - COPY: Copy files from your machine into the container
- RUN: Execute commands during build (installs, setup, etc.)
- EXPOSE: Documents which ports the app uses (informational)
- CMD: Default command when container starts
When you run docker build, Docker reads the Dockerfile and creates an image.
Real Example: A Python Flask App
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Build it with:
docker build -t my-flask-app:1.0 .
Run it with:
docker run -p 5000:5000 my-flask-app:1.0
The -p 5000:5000 maps port 5000 on your machine to port 5000 in the container.
Docker Compose: Running Multiple Containers
Real applications need multiple services. Your Node.js app needs a database. Your backend needs a message queue. Running each with a separate docker run command gets messy fast.
docker-compose.yaml orchestrates multiple containers as a single unit:
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://db:5432/myapp
depends_on:
- db
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Now run everything with:
docker-compose up
That’s it. One command starts your web service and database, connects them, and manages their lifecycle. The depends_on ensures the database starts first.
Why This Matters
Before Docker Compose:
docker run -d --name db postgres:15
docker run -d --name web -p 3000:3000 --link db my-app
# Hope you remember the right order and flags...
With Docker Compose:
docker-compose up
Much better.
Essential Docker Commands
# Build an image
docker build -t image-name:tag .
# Run a container
docker run -p 8000:8000 image-name:tag
# List running containers
docker ps
# List all containers (including stopped)
docker ps -a
# Stop a container
docker stop container-id
# Remove a container
docker rm container-id
# View logs
docker logs container-id
# Execute a command inside a running container
docker exec -it container-id bash
# Push to a registry (Docker Hub, etc.)
docker push username/image-name:tag
# Pull an image from a registry
docker pull username/image-name:tag
Real-World Example Session
# Build your app
docker build -t my-app:1.0 .
# Run it
docker run -d -p 8080:8000 --name running-app my-app:1.0
# Check it's running
docker ps
# See what it's printing
docker logs running-app
# Oops, need to debug. Get a shell
docker exec -it running-app bash
# When done
docker stop running-app
Volumes: Persistent Data
By default, when a container stops, its filesystem is deleted. For databases and important files, you need volumes — persistent storage outside the container.
There are three types:
1. Named Volumes (Recommended for production)
# Create a volume
docker volume create my-data
# Use it
docker run -v my-data:/data myimage
Everything written to /data inside the container is stored in my-data on your machine.
2. Bind Mounts (Good for development)
docker run -v /Users/you/code:/app myimage
Your local /Users/you/code folder is mounted at /app inside the container. Changes sync both ways.
3. Anonymous Volumes
docker run -v /data myimage
Docker creates a temporary volume that’s deleted when the container stops.
In docker-compose:
services:
db:
image: postgres:15
volumes:
- db_data:/var/lib/postgresql/data # Named volume
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # Bind mount
volumes:
db_data:
Networking Basics
Containers are isolated. They can’t talk to each other or the outside world by default.
Port Mapping
docker run -p 8080:3000 myapp
Maps port 8080 on your machine to port 3000 in the container. External traffic reaches the container.
Container-to-Container Communication
With docker-compose, services can reach each other by name:
services:
web:
image: myapp
environment:
DATABASE_HOST: db # Can use 'db' as hostname!
db:
image: postgres:15
The web container can connect to postgres://db:5432 because Docker’s internal DNS resolves db to the database container.
Without docker-compose, use custom networks:
docker network create mynet
docker run --network mynet --name db postgres:15
docker run --network mynet -e DB_HOST=db myapp
Common Docker Pitfalls
1. Large Image Size
Problem: Your image is 1 GB.
Solution: Use smaller base images.
# Bad
FROM ubuntu:22.04
# Good
FROM python:3.11-slim
# Even better for production
FROM python:3.11-alpine
Alpine is tiny (5-40 MB) vs Ubuntu (200+ MB).
2. Layers and Build Cache
Docker builds images in layers. Each instruction creates a new layer.
FROM node:18
COPY . /app # Layer 1
RUN npm install # Layer 2 (takes 2 minutes)
CMD ["npm", "start"] # Layer 3
If you change one file and rebuild, Docker re-uses layers 1 and 3 but rebuilds layer 2 (slow).
Solution: Order instructions by change frequency (least-changing first).
FROM node:18
COPY package.json /app/ # Changes rarely
RUN npm install # Cached most times
COPY . /app # Changes frequently
CMD ["npm", "start"]
3. Running as Root
By default, containers run as root. Bad idea.
FROM node:18
RUN useradd -m app
USER app # Switch to non-root user
COPY . /app
CMD ["npm", "start"]
4. Not Using .dockerignore
Without it, COPY . . copies everything — node_modules, .git, build artifacts, secrets. Huge image.
# .dockerignore
node_modules/
.git/
.env
dist/
build/
5. Forgetting to Clean Up in RUN
# Bad: 2GB image
RUN apt-get update && apt-get install -y gcc
# Good: Cleans up apt cache
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
6. Not Using Multi-Stage Builds
Build artifacts can be huge. Don’t ship them.
# Stage 1: Build
FROM node:18 as builder
COPY . /app
RUN npm install && npm run build
# Stage 2: Runtime (only what's needed)
FROM node:18-alpine
COPY --from=builder /app/dist /app
CMD ["npm", "start"]
The final image only has the compiled output, not build tools.
What Happens When You Run a Container
Understanding the process helps with debugging:
- Check for the image locally — If not found, pull from registry
- Create a new container — Docker allocates a unique ID and filesystem
- Set up networking — Assign an IP, handle port mappings
- Mount volumes — Connect storage
- Set up environment — Apply environment variables
- Start the process — Run the CMD
- Stream output — Show stdout/stderr
When you docker stop, the process gets SIGTERM (polite shutdown). If it doesn’t exit in 10 seconds, SIGKILL (forced).
Docker in Practice: A Real Stack
Here’s a realistic docker-compose setup for a web app:
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:password@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
- db
- cache
volumes:
- ./logs:/app/logs
restart: unless-stopped
db:
image: postgres:15
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
db_data:
Run with docker-compose up -d and everything starts. The web app can reach the database at postgres://db:5432 and Redis at redis://cache:6379.
Summary
Docker containers solve the “works on my machine” problem by packaging your entire environment. Key takeaways:
- Images are blueprints; containers are running instances
- Dockerfiles describe what goes into an image
- docker-compose orchestrates multi-container applications
- Volumes provide persistent storage
- Networks allow container communication
- Minimize image size, use multi-stage builds, avoid root user
With Docker, you ship your application once and it runs the same everywhere — dev, staging, production. That’s the power.