Docker Fundamentals: Images, Containers, Volumes & Networking
Learn Docker from scratch — how images and containers work, writing Dockerfiles, managing volumes and networks, and the commands you'll use every day in a production engineering role.
Before you begin
- Comfortable with basic Linux commands (ls, cd, mkdir, grep)
- A machine with Docker Desktop installed (Mac/Windows) or Docker Engine on Linux
Docker Fundamentals: Images, Containers, Volumes & Networking
Every cloud-native workflow — Kubernetes, CI/CD, local development — runs on containers. Docker is the tool that builds and runs them. Before you can understand Kubernetes you need to understand what's actually running inside a pod: a container, built from a Docker image.
This tutorial covers how Docker works, how to write a Dockerfile, and the commands you'll reach for daily.
Containers vs Virtual Machines
Both isolate workloads, but at different layers:
| Virtual Machine | Container | |
|---|---|---|
| Isolation | Full OS per VM | Shared kernel, isolated processes |
| Startup | 30–60 seconds | Milliseconds |
| Size | GBs | MBs |
| Overhead | High (hypervisor + full OS) | Near-zero |
| Portability | Disk image, heavy | OCI image, registry-native |
A container is not a lightweight VM. It's a group of Linux processes running in isolated namespaces (PID, network, mount, UTS) with cgroup resource limits. The kernel is shared. What's isolated is the process tree, network interfaces, and filesystem view.
Images vs Containers
This is the most important mental model:
- Image — a read-only, layered filesystem snapshot. Think of it as a class definition or a template. Stored in a registry (Docker Hub, ECR, GHCR).
- Container — a running instance of an image. A writable layer on top of the image layers. Think of it as an object instantiated from that class.
You can run many containers from the same image simultaneously. Stopping or deleting a container doesn't affect the image. Changes made inside a container don't persist unless you use volumes.
Installing Docker
Mac/Windows: Install Docker Desktop. It runs a lightweight Linux VM under the hood.
Linux (Debian/Ubuntu):
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # add yourself to docker group
newgrp docker # apply group change without logoutVerify:
docker version
docker run hello-worldRunning Containers
docker run nginx # Pull and run nginx (foreground, Ctrl+C to stop)
docker run -d nginx # Detached (background)
docker run -d -p 8080:80 nginx # Map host port 8080 → container port 80
docker run -d --name my-nginx nginx # Give it a name
docker run -it ubuntu bash # Interactive terminal (-i = stdin, -t = TTY)
docker run --rm ubuntu echo "hello" # Delete container automatically after it exitsManaging running containers
1docker ps # Running containers
2docker ps -a # All containers (including stopped)
3docker stop my-nginx # Graceful stop (SIGTERM → waits → SIGKILL)
4docker kill my-nginx # Immediate SIGKILL
5docker rm my-nginx # Delete stopped container
6docker rm -f my-nginx # Force-delete running container
7docker restart my-nginx # Stop + startInspecting and debugging
1docker logs my-nginx # Stdout/stderr output
2docker logs -f my-nginx # Follow live
3docker logs --tail 50 my-nginx # Last 50 lines
4docker exec -it my-nginx bash # Shell inside running container
5docker exec my-nginx cat /etc/nginx/nginx.conf # Run a single command
6docker inspect my-nginx # Full JSON metadata (IP, mounts, config)
7docker stats # Live CPU/memory usage for all containers
8docker top my-nginx # Processes inside the containerImages
docker images # List local images
docker pull ubuntu:22.04 # Pull a specific tag
docker rmi nginx # Delete a local image
docker image prune # Delete all dangling (untagged) images
docker image prune -a # Delete all unused imagesImage names follow the format registry/repository:tag:
nginx=docker.io/library/nginx:latestubuntu:22.04=docker.io/library/ubuntu:22.04ghcr.io/myorg/myapp:v1.2.3= custom registry
Writing a Dockerfile
A Dockerfile is a script that builds an image layer by layer. Each instruction adds a layer.
1# Start from an official base image
2FROM ubuntu:22.04
3
4# Set working directory (created if it doesn't exist)
5WORKDIR /app
6
7# Install dependencies (combine RUN commands to reduce layers)
8RUN apt-get update && apt-get install -y \
9 python3 \
10 python3-pip \
11 && rm -rf /var/lib/apt/lists/*
12
13# Copy requirements first (exploits layer cache — see tutorial 49)
14COPY requirements.txt .
15RUN pip3 install -r requirements.txt
16
17# Copy application code
18COPY . .
19
20# Set environment variable
21ENV APP_ENV=production
22
23# Expose the port the app listens on (documentation only — doesn't publish)
24EXPOSE 8000
25
26# Default command when container starts
27CMD ["python3", "app.py"]Key instructions
| Instruction | Purpose |
|---|---|
FROM | Base image — every Dockerfile starts here |
WORKDIR | Set working directory for subsequent instructions |
COPY | Copy files from build context into image |
RUN | Execute a command and commit the result as a new layer |
ENV | Set environment variable (available at build and runtime) |
ARG | Build-time variable (not available at runtime) |
EXPOSE | Document which port the container listens on |
CMD | Default command (can be overridden at docker run) |
ENTRYPOINT | Fixed executable (CMD becomes its arguments) |
CMD vs ENTRYPOINT
1# CMD only — fully replaceable
2CMD ["python3", "app.py"]
3# docker run myimage python3 other.py ← replaces CMD entirely
4
5# ENTRYPOINT + CMD — fixed executable, replaceable arguments
6ENTRYPOINT ["python3"]
7CMD ["app.py"]
8# docker run myimage other.py ← runs python3 other.pyUse ENTRYPOINT when your container is a single-purpose tool and the executable should never change. Use CMD alone for flexibility.
Building an image
docker build -t myapp:latest . # Build from Dockerfile in current dir
docker build -t myapp:1.0.0 . # Specific tag
docker build -f Dockerfile.prod -t myapp:prod . # Custom Dockerfile name
docker build --no-cache -t myapp . # Ignore layer cacheThe . at the end is the build context — the directory Docker sends to the daemon. Keep it small by using .dockerignore.
.dockerignore
Like .gitignore, but for the build context. Prevents unnecessary files from being sent to the daemon and accidentally included in the image.
# .dockerignore
.git
.gitignore
node_modules
*.log
.env
dist
__pycache__
.pytest_cache
*.pyc
README.md
Always create a .dockerignore. Sending node_modules in the context can add gigabytes and seconds to every build.
Volumes — Persisting Data
Container filesystems are ephemeral — data written inside a container is lost when the container is deleted. Volumes solve this.
Bind mounts — map a host path into the container
docker run -v /host/path:/container/path nginx
docker run -v $(pwd)/html:/usr/share/nginx/html nginx # Mount current dirGood for: development (hot-reload), reading host config, writing logs to the host.
Named volumes — managed by Docker
docker volume create mydata # Create a named volume
docker run -v mydata:/var/lib/postgresql/data postgres # Use it
docker volume ls # List volumes
docker volume inspect mydata # Volume details including mount point
docker volume rm mydata # Delete volume
docker volume prune # Delete all unused volumesGood for: production data persistence, sharing data between containers.
Key difference
Bind mounts depend on the host path existing. Named volumes are managed by Docker and work the same on any host — better for portability and production use.
Networking
Default networks
docker network ls # List networks
docker network inspect bridge # Inspect the default bridgeThree built-in networks:
bridge(default) — containers can talk to each other by IP; isolated from hosthost— container shares the host's network stack; no port mapping needednone— no network access
User-defined bridge networks
docker network create mynet
docker run -d --network mynet --name db postgres
docker run -d --network mynet --name app myappOn a user-defined network, containers can resolve each other by name (db, app). On the default bridge network, they can only communicate by IP.
# Inside app container:
ping db # resolves because they share mynet
psql -h db -U postgresPort mapping
docker run -p 8080:80 nginx # host:container
docker run -p 127.0.0.1:8080:80 nginx # Bind to localhost only (safer)
docker run -P nginx # Map all EXPOSE'd ports to random host portsEXPOSE in the Dockerfile doesn't publish ports — it's documentation. -p actually publishes them.
A Complete Example
A Node.js app with a Dockerfile:
1FROM node:20-alpine
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci --omit=dev
5COPY . .
6EXPOSE 3000
7CMD ["node", "server.js"]Build, run, and test:
docker build -t my-node-app:latest .
docker run -d -p 3000:3000 --name app my-node-app:latest
curl http://localhost:3000/health
docker logs app
docker exec -it app sh # Alpine uses sh, not bashCommon Patterns
Always clean up in the same RUN layer:
1# Good — no cache files in image
2RUN apt-get update && apt-get install -y curl \
3 && rm -rf /var/lib/apt/lists/*
4
5# Bad — apt cache is baked into a layer
6RUN apt-get update
7RUN apt-get install -y curlNever run as root in production:
RUN useradd -r -u 1001 appuser
USER appuser
CMD ["node", "server.js"]Use specific tags, not latest:
# Bad — unpredictable, breaks on image updates
FROM node:latest
# Good — reproducible builds
FROM node:20.14-alpine3.20What's Next
- Docker Multi-Stage Builds & Image Optimisation — slim images, layer caching, non-root users, distroless bases
- Docker Compose for Local Development — multi-container environments, service dependencies, environment config
These tutorials are part of the Container Foundations learning path. After Compose, you have everything you need to tackle Kubernetes — where containers run at scale.
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.