Docker: Containers, from Zero to Hero (z2h)
#docker#containerization#virtualization#containers#devops#security
Docker is a platform for building, shipping, and running applications in containers. Containers package your app and its dependencies into a single runnable unit that behaves the same everywhere: on your laptop, in CI/CD, or in the cloud.
Why containers?
- Consistency: Same image runs the same way in dev, test, and production.
- Isolation: Each container has its own filesystem and network namespace.
- Portability: Build once, run on any host that supports Docker (or another container runtime).
- Efficiency: Containers share the host kernel and start quickly compared to virtual machines.
Docker architecture
Docker uses a client–server model. When you run docker run or docker build, the Docker client (CLI) talks to the Docker daemon (dockerd), which does the real work.
+------------------+ REST API (e.g. over socket) +--------------------+
| Docker Client | -----------------------------> | Docker Daemon |
| (docker CLI) | | (dockerd) |
+------------------+ +--------+-----------+
|
+------------------------------------------------------------+------------------+
| v |
| +-------------+ +-------------+ +-----------------+ +------------------+ |
| | Images & | | containerd | | runc | | Network, | |
| | layer | | (runtime |->| (OCI runtime) | | volumes,build, | |
| | storage | | lifecycle) | | creates actual | | etc. | |
| | | | | | container | | | |
| +-------------+ +-------------+ +-----------------+ +------------------+ |
+-------------------------------------------------------------------------------+
Host
- Docker daemon (dockerd): Builds images, manages containers, images, networks, and volumes. Listens for requests from the client. It is a separate process from containerd and runc; it does not include them inside a single binary.
- containerd: A separate container runtime process that manages the lifecycle of containers (start, stop, delete). Dockerd delegates container execution to containerd over an API/socket. When you install Docker Engine, containerd is typically installed and run as its own process.
- runc: A separate binary (OCI runtime) that creates and runs the actual process in a constrained environment (namespaces, cgroups). Containerd invokes runc (e.g. fork/exec) to create each container; runc is not a long-running daemon.
- Image and layer storage: Images and their layers are stored on disk (e.g. under
/var/lib/dockeron Linux). The daemon uses a storage driver (e.g.overlay2) to assemble layers into a container filesystem.
So the flow for docker run is: Client → dockerd → containerd → runc → your process runs inside the container. All of these run on the host (or inside the Docker Desktop VM), but dockerd, containerd, and runc are three distinct components, not one.
Docker CLI (docker)
The Docker CLI is the docker command you run in a terminal. It is a client only: it does not run containers, build images, or store data. It parses your command and arguments, then sends a request to the Docker daemon over an API. The daemon does the work and returns a response; the CLI formats and prints the result (or streams logs, build output, etc.).
How the CLI talks to the daemon:
- Default: The CLI connects to the daemon over a Unix socket (Linux, macOS in Docker Desktop) or a named pipe (Windows). On Linux the default is
unix:///var/run/docker.sock; the CLI and daemon must both have access to that socket. - Remote daemon: You can point the CLI at another host’s daemon by setting
DOCKER_HOST(e.g.export DOCKER_HOST=tcp://192.168.1.10:2375). Then everydockercommand runs against that host. Used for remote management or when the daemon runs in a VM (Docker Desktop sets this up for you). - TLS: For remote connections, use TLS (e.g.
DOCKER_HOST=tcp://host:2376, with certs) so the API is encrypted and authenticated.
Command structure: Commands are grouped by resource: docker run, docker image build, docker container ls, docker network create, docker volume inspect. Many short forms exist (e.g. docker ps = docker container ls, docker build = docker image build). The CLI validates options and passes them to the API; invalid options or daemon errors are reported back.
Configuration: The CLI reads a config file so you can set defaults (e.g. default registry, format for docker ps). On Linux: ~/.config/docker/config.json; on Windows: %USERPROFILE%\.docker\config.json. You can set the context (which daemon to use) with docker context use <name>; Docker Desktop creates a context for the local engine.
Summary: The CLI is a thin client: parse command → API request → daemon → response → output. No containers or images live in the CLI; they all live on the daemon (or the host where the daemon runs).
Docker Daemon (dockerd)
The Docker daemon (dockerd) is the long-running process that owns all Docker objects: images, containers, networks, volumes, and the build cache. It listens for API requests (from the CLI or other clients), performs the work, and returns results. It runs in the background (as a system service on Linux, or inside the Docker Desktop VM on Mac/Windows). Dockerd does not include containerd or runc—those are separate processes or binaries on the host; the daemon delegates container lifecycle to containerd, which in turn calls runc.
Responsibilities:
| Area | What the daemon does |
|---|---|
| Containers | Create, start, stop, delete; attach to containerd/runc for lifecycle; manage container state and metadata. |
| Images | Pull, push, build, tag, remove; manage layer storage via the storage driver; store metadata and manifest. |
| Networks | Create and delete networks (bridge, overlay); attach containers to networks; manage IP assignment and DNS; on Linux, create bridges and veth pairs and iptables rules. |
| Volumes | Create and delete volumes; mount them into containers; track which container uses which volume. |
| Build | Read Dockerfile, execute build steps, create new layers and images; use build cache. |
| Plugins | Load and talk to plugins (e.g. volume drivers, network drivers) if configured. |
API: The daemon exposes a REST API (HTTP/HTTPS). The CLI sends HTTP requests (e.g. POST /containers/create, GET /containers/json). Other tools (Compose, Kubernetes, CI systems) can use the same API. The API is versioned (e.g. /v1.41/...); the CLI and daemon negotiate a version so older clients can still talk to newer daemons within reason.
Configuration: On Linux, the daemon is configured via /etc/docker/daemon.json and (optionally) systemd overrides. Common options:
- Storage driver:
"storage-driver": "overlay2"(default on modern Linux). - Data root:
"data-root": "/var/lib/docker"(where images, containers, volumes metadata and data live). - Logging:
"log-driver": "json-file"and"log-opts"(max-size, max-file) for container stdout/stderr. - Live restore:
"live-restore": trueso containers keep running when the daemon is restarted (optional). - Insecure registries:
"insecure-registries": ["my-registry:5000"]for HTTP registries.
After editing daemon.json, restart the daemon (e.g. sudo systemctl restart docker on Linux). Invalid JSON or unsupported options can prevent the daemon from starting.
Running the daemon:
- Linux: Usually managed by systemd:
systemctl start docker,systemctl enable docker. The daemon runs as root (or root-equivalent) so it can create namespaces, mount filesystems, and manage iptables. Users in thedockergroup can access the socket and thus run containers without sudo. - Docker Desktop (Mac/Windows): The daemon runs inside the Linux VM (or WSL 2); the Desktop app starts the VM and the daemon. You do not run
dockerdmanually.
Logs and debugging: Daemon logs go to the host’s logging system (e.g. journalctl -u docker on Linux with systemd). With dockerd in debug mode (dockerd --debug or "debug": true in config), it logs more detail; useful for troubleshooting, not for production.
Summary: The daemon is the single point of control for all Docker resources on a host. The CLI (and Compose, etc.) are clients that talk to it over the API; the daemon does the real work and persists state on disk (e.g. under /var/lib/docker).
How Docker runs on different operating systems
Docker’s architecture is the same everywhere (client, daemon, containerd, runc), but where the daemon and containers actually run depends on the host OS. On Linux (including Ubuntu), containers run directly on the host kernel. On macOS and Windows, Docker uses a small Linux environment (a VM or WSL 2) so that Linux containers can run; the daemon and all containers live inside that environment.
Linux (native) — Ubuntu and other distributions
On Linux, Docker runs natively. There is no extra VM: the Docker daemon is a normal process, and containers are processes on the same kernel, isolated by namespaces and cgroups.
+----------------------------------------------------------------+
| Linux host (e.g. Ubuntu) |
| Kernel: namespaces (pid, net, mnt, uts, ipc, user), cgroups |
| |
| +---------------+ +----------------+ +----------------+ |
| | Docker daemon | | Container A | | Container B | |
| | (dockerd) | | (process tree | | (process tree | |
| | | | in namespaces)| | in namespaces)| |
| +---------------+ +----------------+ +----------------+ |
| | | | |
| +--------------------+--------------------+ |
| v |
| Linux kernel (one kernel for all) |
+----------------------------------------------------------------+
- Namespaces: Give each container its own view of PIDs, network, mount points, hostname, IPC, and (with user namespaces) UIDs/GIDs. The host and other containers are isolated.
- Cgroups (control groups): Limit and account for CPU, memory, and I/O so one container cannot starve the host or others.
- Storage: The daemon uses a storage driver such as overlay2 (default on modern Linux). Overlay2 uses the kernel’s OverlayFS to stack image layers and a writable layer; all of this is on the host filesystem (e.g.
/var/lib/docker). - Networking: Bridge, host, or custom networks are implemented with Linux bridges, veth pairs, and iptables/nftables on the host.
Ubuntu is just one Linux distribution; the same model applies to Debian, RHEL, Fedora, etc. You install the Docker Engine (or containerd + Docker CLI), and the daemon runs as a systemd service. No VM is involved; performance and resource use are the same as any other process on the host.
macOS (Docker Desktop)
macOS does not share the Linux kernel, so it cannot run Linux containers directly. Docker Desktop for Mac runs a lightweight Linux VM; the Docker daemon and all containers run inside that VM.
+--------------------------- macOS host --------------------------+
| Docker CLI (docker) — runs as a native Mac process |
| | |
| | REST API (e.g. over Unix socket or TCP) |
| v |
| +---------------------- Linux VM (Docker Desktop) ----------+ |
| | Docker daemon (dockerd) | |
| | containerd → runc | |
| | Containers (Linux processes inside the VM) | |
| | Storage: overlay2 on VM disk | |
| +-----------------------------------------------------------+ |
| ^ |
| | Port forwarding, volume mounts (e.g. /Users → VM) |
+-----------------------------------------------------------------+
- Why a VM? Linux images and binaries are built for the Linux kernel. On the Mac, the only way to run them is inside a Linux kernel, so Docker Desktop bundles a minimal Linux (often based on Alpine or similar) in a VM (historically HyperKit, now often Lima or a custom hypervisor).
- Client on Mac, daemon in VM: When you run
docker runin Terminal, the CLI is the Mac binary; it talks over a socket or TCP to the daemon inside the VM. To you it feels like “Docker on Mac,” but the real work happens in the VM. - Volumes: When you use
-v /Users/you/project:/app, the Mac path is mounted into the VM (via 9p, gRPC-fuse, or similar), and the container sees it inside the VM. So there is a chain: Mac filesystem → VM → container. For large trees this can be slower than native Linux. - Networking: Ports you publish (e.g.
-p 8080:80) are forwarded from the Mac to the VM and then to the container. From the Mac,localhost:8080reaches the container.
So on macOS, Docker “runs” in the sense that the CLI is on the Mac, but the engine and all containers run inside a Linux VM.
Windows
On Windows there are two cases: Linux containers (most common) and Windows containers.
Linux containers on Windows (Docker Desktop)
Most people run Linux images on Windows. Windows does not have the Linux kernel, so Docker Desktop uses a Linux environment so that the daemon and containers can run:
- WSL 2 (recommended): WSL 2 is a real Linux kernel in a lightweight VM. Docker Desktop can run the Docker daemon inside WSL 2 (e.g. in the default
docker-desktopdistro). All containers are Linux processes in that kernel. The WindowsdockerCLI talks to the daemon in WSL 2 over a named pipe or socket. - Hyper-V (legacy): On older setups or when WSL 2 is not used, Docker Desktop may use a small Hyper-V VM with a Linux guest; the daemon and containers run in that VM. The Windows CLI talks to the daemon in the VM.
+--------------------------- Windows host -----------------------------+
| Docker CLI (docker.exe) — runs as a Windows process |
| | |
| | Named pipe / TCP (to daemon in WSL 2 or Hyper-V VM) |
| v |
| +---------------------- WSL 2 (Linux kernel) or Hyper-V VM ------+ |
| | Docker daemon (dockerd) | |
| | containerd → runc | |
| | Containers (Linux processes) | |
| +----------------------------------------------------------------+ |
| ^ |
| | Port forwarding, bind mounts (e.g. C:\Users → VM) |
+----------------------------------------------------------------------+
- Volumes: A path like
-v C:\Users\you\project:/appis translated so the Windows path is available inside the Linux environment (e.g./run/desktop/mnt/host/c/Users/you/project). Again, cross-boundary I/O can be slower than on Linux. - Networking: Similar to Mac: published ports are forwarded from Windows to the Linux environment and then to the container.
So on Windows, for Linux containers, Docker “runs” with the CLI on Windows and the engine and containers inside WSL 2 (or a Hyper-V Linux VM).
Windows containers (optional)
Windows can also run Windows containers: images based on Windows Server Core or Nano Server, running on the Windows kernel. In that mode, the Docker daemon uses the Windows container runtime (process isolation or Hyper-V isolation) instead of runc. This is a different path (Windows images, Windows host) and is separate from the “Docker on Windows” setup most developers use for Linux images.
Summary: Docker and the OS
| Host OS | Where daemon & containers run | Notes |
|---|---|---|
| Linux | Directly on the host kernel | Native; namespaces, cgroups, overlay2; no VM. Same for Ubuntu, etc. |
| macOS | Inside a Linux VM (Docker Desktop) | CLI on Mac; daemon and containers in VM; volumes and ports bridged. |
| Windows | Inside WSL 2 or a Hyper-V Linux VM | CLI on Windows; daemon and containers in Linux; volumes/ports bridged. |
| Windows | On Windows (Windows containers only) | Daemon and Windows containers on Windows kernel; different use case. |
So: Docker runs on the OS in the sense that the client runs there, but Linux containers only ever run on a Linux kernel—natively on Linux, or inside a Linux VM / WSL 2 on Mac and Windows.
How Docker networking works
Each container has its own network namespace: its own network interfaces, IP address, and routing table. By default, containers can talk to each other and to the outside world only according to the network they are attached to and whether you publish ports to the host. Docker creates and manages virtual networks using Linux bridges, veth (virtual Ethernet) pairs, and (on Linux) iptables/nftables.
Network drivers overview
Docker supports several network drivers that define how containers get an IP and how they reach each other and the host.
| Driver | Description | Typical use |
|---|---|---|
| bridge | Default. Containers on the same bridge get IPs from a private subnet and can reach each other; outbound traffic is NAT’d through the host. | Single host, multiple containers; default for docker run and Compose. |
| host | Container shares the host’s network stack (no separate namespace). No network isolation; container uses host IP and ports directly. | When you need maximum performance or host networking (e.g. some monitoring). |
| none | Container has no external interfaces (only loopback). | Isolated workloads that do not need network. |
| overlay | Multi-host networks for Swarm; VXLAN etc. across nodes. | Docker Swarm clusters. |
The rest of this section focuses on bridge networking (default) and port publishing, which you use most of the time.
Default bridge network
When you run a container without specifying a network (e.g. docker run nginx), Docker attaches it to the built-in bridge network. On Linux, this is implemented with:
- docker0: A Linux bridge (virtual switch) created by the daemon. It has an IP on a private subnet (e.g.
172.17.0.1/16). - veth pairs: Each container gets one end of a veth pair; the other end is plugged into docker0. So each container has a virtual NIC that is “connected” to the bridge.
- IP assignment: The daemon assigns an IP from the bridge subnet to the container’s side of the veth (e.g.
172.17.0.2). Inside the container, this is the main interface (e.g.eth0). - Outbound traffic: Traffic from the container goes to the bridge, then the host forwards it. iptables (or nftables) does source NAT (SNAT) so that outbound packets appear to come from the host; replies are DNAT’d back to the container. So the container can reach the internet without a public IP.
- Inbound (from host or internet): By default nothing is exposed. You must publish ports (see below) so the host forwards specific ports to a container.
+---------------------- Linux host ----------------------+
| |
| docker0 (bridge) 172.17.0.1/16 |
| | | |
| v v |
| +--------+ +--------+ |
| | veth | | veth | (other end in container |
| | (host | | (host | network namespace) |
| | side) | | side) | |
| +---+----+ +---+----+ |
| | | |
| +---+----+ +---+----+ +------------------+ |
| | Container A | Container B | iptables: NAT | |
| | 172.17.0.2 | 172.17.0.3 | (outbound SNAT, | |
| | eth0 | eth0 | publish DNAT) | |
| +--------------+--------------+------------------+ |
| | |
| Host NIC (e.g. eth0) → internet |
+--------------------------------------------------------+
- Container-to-container on default bridge: Containers can ping each other by IP (e.g.
172.17.0.3). They do not resolve each other by name on the default bridge; automatic DNS names are only available on user-defined networks. - Container to host: From the container, the host’s bridge IP (e.g.
172.17.0.1) is the gateway. To reach host services (e.g. DB on host), use that IP or the host’s real IP; avoidlocalhostfrom inside the container (that is the container’s own loopback).
User-defined bridge networks
For multi-container apps (e.g. app + database), a user-defined bridge is usually better than the default bridge:
- DNS by container name: Containers on the same user-defined network can resolve each other by name (the container name or service name in Compose). Docker runs an embedded DNS server that resolves these names to the container’s IP on that network.
- Isolation: Only containers attached to that network can talk to each other on it. You can have several user-defined networks and attach containers to the ones they need.
- Same behavior as Compose: Docker Compose creates a user-defined bridge per project and attaches all services to it; that’s why
appcan connect todb:5432using the service namedb.
Create and use a user-defined bridge:
# Create a bridge network
docker network create mynet
# Run containers on it (by name for DNS)
docker run -d --name web --network mynet my-app:latest
docker run -d --name db --network mynet postgres:16-alpine
# From inside 'web', you can ping or connect to 'db' by name
docker exec web ping db
On Linux, a user-defined bridge is again a bridge + veth pairs; the only difference from the default bridge is the subnet (e.g. 172.18.0.0/16) and the fact that Docker assigns names and runs DNS for that network.
Port publishing (-p / —publish)
Publishing a port binds a port on the host and forwards traffic to a port in the container. That is how you expose a containerized service (e.g. a web app) to the host and thus to other machines (or localhost on the host).
Syntax: -p [host_ip:]host_port:container_port (e.g. -p 8080:80 or -p 127.0.0.1:8080:80).
On Linux:
- The daemon allocates the container an IP on the bridge (as above). When you
-p 8080:80, Docker adds iptables rules: traffic to the host’s port 8080 is DNAT’d to the container’s IP and port 80. So when you curllocalhost:8080on the host, the kernel forwards the packet to the container’s port 80. - If you omit the host IP, the rule applies to all host interfaces (0.0.0.0), so the port is reachable from outside the host. Using
127.0.0.1:8080:80binds only to loopback, so only the host can access it.
On macOS and Windows (Docker Desktop):
- The daemon and containers run inside a VM (or WSL 2). The host (Mac/Windows) does not have the container’s bridge or iptables. Docker Desktop sets up port forwarding from the host into the VM: when you use
-p 8080:80, the Mac/Windows port 8080 is forwarded to the VM, and inside the VM the usual DNAT forwards to the container. So you still uselocalhost:8080on the host; the forwarding is just one extra hop (host → VM → container).
Summary:
- Publish = make a container port reachable from the host (and possibly the world if bound to 0.0.0.0).
- No publish = container is only reachable from other containers on the same network (by IP or name on user-defined networks).
Data flow: from the internet to a container
Example: you run docker run -p 8080:80 nginx. Someone (or you) opens http://host-ip:8080.
- Packet arrives at the host on port 8080.
- On Linux: The host kernel’s iptables DNAT rule changes the destination to the container’s IP (e.g.
172.17.0.2) and port 80. The packet is delivered to the container’s veth and appears on the container’seth0; the process in the container (nginx) listens on 80 and handles it. The reply goes back through the bridge; iptables SNAT makes it look like it came from the host, so the client gets the response. - On Mac/Windows: The host forwards port 8080 into the VM; inside the VM, the same DNAT-to-container happens. Reply path is VM → host → client.
So from the client’s point of view, “host:8080” is the service; Docker hides the container’s private IP and port.
Networking with Docker Compose
Compose creates a default network per project (named e.g. projectname_default) and attaches every service to it. So all services are on one user-defined bridge and can reach each other by service name (the key under services:).
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
# no ports: only 'app' needs to reach db; no need to publish 5432 to host
- app resolves
dbto the PostgreSQL container’s IP and connects to port 5432. No-pondbis required for that. - ports: “3000:3000” publishes the app’s port 3000 so the host (and you) can access the app at
localhost:3000.
You can define additional networks and attach services to them for finer-grained isolation or multi-project setups.
Summary: Docker networking
| Concept | Detail |
|---|---|
| Network namespace | Each container has its own; isolation of interfaces, IPs, and routing. |
| Default bridge | docker0 + veth; containers get private IPs; outbound NAT; no DNS by name. |
| User-defined bridge | Same idea; DNS by container name; use for multi-container apps. |
| Port publish (-p) | Binds host port and forwards to container; on Linux via iptables DNAT; on Mac/Windows via host→VM forwarding. |
| Compose | One network per project; services reach each other by service name; publish only what the host needs. |
How Docker storage works
A container’s filesystem is made of read-only image layers plus a writable layer on top (see Docker image layers). Anything written to the writable layer is ephemeral: it is lost when the container is removed. For persistent or shared data, Docker provides volumes and bind mounts (and optionally tmpfs). This section explains where data lives and when to use each option.
What the container sees: layers + writable layer + mounts
When a container runs, the storage driver (e.g. overlay2 on Linux) merges all image layers into a single read-only view and adds a writable layer for that container. On top of that, you can mount volumes or host directories. So the process inside the container sees one filesystem tree: some paths are from the image, some from the writable layer, and some from a volume or bind mount.
+---------------------- Container view (single filesystem) ----------------------+
| /app <- from image layers or writable layer |
| /var/lib/postgresql/data <- from volume (persistent, managed by Docker) |
| /host/config <- from bind mount (host path) |
| /tmp <- from writable layer or tmpfs |
+--------------------------------------------------------------------------------+
| | |
v v v
+----------------+ +----------------------+ +----------------------+
| Image layers | | Volume (e.g. pgdata) | | Host directory |
| + writable | | /var/lib/docker/ | | /home/you/project |
| layer | | volumes/pgdata/_data | | (bind mount) |
+----------------+ +----------------------+ +----------------------+
- No volume or bind: Writes go to the writable layer. Fast, but tied to the container; remove the container and the data is gone.
- Volume or bind: Writes go to the volume or host path; they outlive the container and can be shared or reused.
Storage drivers (image and container layer)
On Linux, the daemon uses a storage driver to store image layers and the container’s writable layer. The default is overlay2.
- overlay2: Uses the kernel’s OverlayFS. Each image layer is a directory (lower); the container’s writable layer is the upper directory. Reads merge through the stack; writes go only to the upper layer. All of this lives under the Docker root (e.g.
/var/lib/docker/overlay2/). - Location: Image and container layer data are under
/var/lib/docker/(or the equivalent in the Docker Desktop VM on Mac/Windows). You rarely touch these dirs directly; usedocker system df -vto see space usage.
The writable layer is created when the container starts and removed when the container is removed. So any data only in that layer is ephemeral.
Volumes (named and anonymous)
Volumes are the preferred way to persist data. Docker creates and manages a directory (on Linux typically under /var/lib/docker/volumes/) and mounts it into the container at a path you choose. Volumes outlive containers and can be shared between containers.
| Aspect | Detail |
|---|---|
| Lifecycle | Created when you first use the volume (e.g. docker run -v mydata:/app/data); removed only with docker volume rm (or docker volume prune). |
| Location | On Linux: /var/lib/docker/volumes/<name>/_data. On Mac/Windows: inside the Docker Desktop VM. |
| Use for | Database data, caches, uploaded files—anything that must persist across container restarts or be shared. |
Named volume (you choose the name):
# Create a named volume
docker volume create pgdata
# Use it in a container (Docker creates it if it doesn't exist)
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16-alpine
# Inspect where it lives (on Linux)
docker volume inspect pgdata
Anonymous volume (Docker assigns an ID): same behavior, but the name is a long ID; useful when you don’t need to refer to the volume by name (e.g. -v /var/lib/postgresql/data with no host part in older syntax, or Compose without a name). Prefer named volumes so you can reuse and identify them.
Syntax: -v volume_name:/path/in/container or --mount type=volume,source=pgdata,target=/var/lib/postgresql/data. With --mount you can add options (e.g. readonly).
Bind mounts
A bind mount is a host directory or file mounted directly into the container. Docker does not create or manage the path; it must exist on the host. Changes in the container are visible on the host and vice versa.
| Aspect | Detail |
|---|---|
| Lifecycle | Tied to the host path; no Docker-specific lifecycle. |
| Location | Any path on the host (e.g. /home/you/app, C:\Users\you\app on Windows). |
| Use for | Dev: mount source code so you edit on the host and run in the container. Config files, certificates; anything that already lives on the host. |
# Mount host directory into container (path must exist on host)
docker run -v /home/you/myapp:/app my-app:latest
# Read-only bind mount
docker run -v /home/you/config:/app/config:ro my-app:latest
On macOS and Windows: The host path is first exposed to the Docker VM (e.g. via file sharing settings), then mounted into the container. So you use a Mac or Windows path; I/O crosses the host–VM boundary and can be slower than on Linux.
Syntax: -v /host/path:/container/path[:ro] or --mount type=bind,source=/host/path,target=/container/path,readonly.
tmpfs mounts
A tmpfs mount is RAM-backed; nothing is written to disk. Data is lost when the container stops. Use it for sensitive or temporary data (e.g. secrets, scratch space) when you don’t want it on the container’s writable layer or on a volume.
docker run --tmpfs /tmp:noexec,nosuid,size=64m my-app:latest
Syntax: --tmpfs /path or --mount type=tmpfs,target=/path,tmpfs-mode=1777,tmpfs-size=64m.
Volumes vs bind mounts vs writable layer
| Type | Persists after container remove? | Managed by Docker? | Typical use |
|---|---|---|---|
| Writable layer | No | Yes (per container) | Ephemeral writes; logs, temp files you don’t need to keep. |
| Volume | Yes | Yes (under /var/lib/docker/volumes/) | DB data, caches, persistent app data; sharing between containers. |
| Bind mount | Yes (lives on host) | No | Dev: source code; host config/certs. |
| tmpfs | No | In-memory | Secrets, temporary data; not on disk. |
Storage with Docker Compose
Compose can define named volumes and bind mounts per service. Named volumes are created automatically and outlive the stack unless you remove them.
services:
app:
build: .
volumes:
- ./src:/app/src # bind mount: dev code on host
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data # named volume: persist DB data
volumes:
pgdata: # declared so you can reference it; Docker creates it
- pgdata is a named volume; the database data persists across
docker compose down(usedocker compose down -vto remove volumes). - ./src:/app/src is a bind mount; the app container sees your local
srcdirectory; edits on the host are visible in the container.
Summary: Docker storage
| Concept | Detail |
|---|---|
| Container filesystem | Image layers (read-only) + writable layer + optional volume/bind/tmpfs mounts. |
| Writable layer | Ephemeral; removed with the container; use for non-persistent data. |
| Volume | Docker-managed directory; persists; use for DB data, caches, shared data. |
| Bind mount | Host path mounted into container; good for dev (code) and host config. |
| tmpfs | In-memory; no disk; good for secrets or temporary data. |
Docker image layers
An image is not a single file; it is made of read-only layers stacked on top of each other. Each Dockerfile instruction that changes the filesystem (e.g. RUN, COPY, ADD) typically creates a new layer.
How layers work
- Read-only: Every layer is immutable. Once built, it is reused by any image or container that needs it.
- Union mount (e.g. OverlayFS): When you run a container, the storage driver merges all image layers into a single view. The container also gets a thin writable layer on top for any changes at runtime (e.g. creating files). That writable layer is discarded when the container is removed unless you use a volume.
- Layering and caching: If you rebuild an image, Docker reuses cached layers until an instruction changes. Then that instruction and everything after it are rebuilt.
Example: this Dockerfile produces several layers:
FROM node:20-alpine # Layer 1: base image (many layers from node:20-alpine)
WORKDIR /app # Layer 2: create /app (metadata + empty dir in layer)
COPY package*.json ./ # Layer 3: add files
RUN npm ci # Layer 4: install deps (new files in this layer)
COPY . . # Layer 5: add app code
CMD ["node", "app.js"] # Layer 6: metadata only (no new filesystem layer)
Only instructions that change the filesystem add a new filesystem layer; WORKDIR, ENV, CMD, EXPOSE etc. often add only metadata.
Why layer order matters
- Cache reuse: Put instructions that change less often (e.g.
COPY package*.json+RUN npm ci) before instructions that change often (e.g.COPY . .). That way code changes do not invalidate the dependency-install layer. - Smaller images: Fewer or smaller layers mean less to pull and store. Combine related
RUNcommands to avoid extra layers (e.g.RUN apt update && apt install -y ...in one line).
Inspecting layers
# Show the layers (history) of an image
docker history my-app:latest
# Inspect image details, including layer IDs and config
docker image inspect my-app:latest
Summary: image layers
| Idea | Detail |
|---|---|
| Layer | One read-only filesystem delta (or metadata). Many layers form one image. |
| Union filesystem | Layers are stacked; the container sees one merged filesystem plus a writable top layer. |
| Cache | Rebuild reuses layers until an instruction changes; then that layer and all below are rebuilt. |
| Best practice | Order Dockerfile so rarely changing steps come first; combine RUN steps to reduce layers. |
Security best practices
Containers share the host kernel, so a compromise inside a container can sometimes affect the host or other containers. Hardening images and runtime reduces risk.
Run as non-root
By default the process in the container runs as root (UID 0). If the app or a dependency is exploited, the attacker has root inside the container. Use the USER instruction to run as an unprivileged user.
FROM node:20-alpine
WORKDIR /app
# Create a non-root user
RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]
If your base image does not provide a user, create one (e.g. with adduser on Alpine or useradd on Debian/Ubuntu). Ensure the user has read (and where needed write) access to app directories and none to sensitive paths.
Do not store secrets in the image
Secrets (passwords, API keys, certs) must not be baked into image layers. They would be visible to anyone with access to the image and persist in layer history. Use environment variables or secret mounts (e.g. Docker secrets, Kubernetes secrets, or a vault) injected at runtime. For build-time secrets (e.g. private npm token), use BuildKit secrets (--secret mount) so they are not written into a layer.
Use multi-stage builds to shrink attack surface
Multi-stage builds use one stage for building (compilers, dev deps) and a final stage that copies only the built artifact. The final image does not contain build tools or source code, so there is less to exploit and fewer CVEs from unused packages.
Pin base image tags
Avoid FROM node:latest or FROM alpine:latest. Pin to a digest or a specific tag (e.g. node:20.2-alpine or node:20-alpine@sha256:...). Rebuild periodically to pick up security fixes, but in a controlled way so you are not surprised by base changes.
Read-only root filesystem and drop capabilities
Where the app does not need to write to the filesystem (except perhaps a tmpfs or volume), run with read-only root filesystem so an attacker cannot persist files:
docker run --read-only --tmpfs /tmp -p 8080:3000 my-app:latest
Drop unneeded Linux capabilities (e.g. --cap-drop=ALL and add back only what is required). This limits what the process can do at the kernel level.
Limit resources
Use cgroups to cap CPU and memory so a compromised or buggy container cannot starve the host or other containers:
docker run -d --memory=256m --cpus=0.5 -p 8080:3000 my-app:latest
In Compose, set deploy.resources.limits (or the older mem_limit / cpus under the service).
Secure the daemon and API
- Docker socket: Access to
/var/run/docker.sockis equivalent to root on the host. Do not mount the socket into containers unless the container is a trusted control-plane component (e.g. CI runner). - Remote API: If the daemon listens on TCP, use TLS and restrict who can connect. Prefer the Unix socket for local access.
- Rootless mode: On Linux you can run the daemon in rootless mode so it runs as an unprivileged user; containment is improved if the daemon is compromised.
Scan images for vulnerabilities
Use image scanning (e.g. Docker Scout, Trivy, or your registry’s built-in scanner) in CI to detect known CVEs in base images and dependencies. Fix or upgrade bases and rebuild.
Best practices for image sizing
Smaller images pull and start faster, use less disk and memory, and have a smaller attack surface. The following practices help keep image size down.
Choose a small base image
- Alpine (
alpine,node:20-alpine,python:3.12-alpine) is often tens of MB. Use it when glibc compatibility is not required (Alpine uses musl). - Distroless images (e.g. from Google) contain only the runtime and your app—no shell or package manager. Good for production when you do not need to debug inside the container.
- Slim / -slim variants (e.g.
debian:bookworm-slim,node:20-slim) are smaller than full Debian/Ubuntu. - Avoid full OS images (e.g.
ubuntu:22.04without-slim) unless you need many system packages.
| Base type | Typical size (approx.) | Use when |
|---|---|---|
| Alpine | 5–50 MB | General apps; musl is acceptable. |
| Slim | 50–100 MB | You need glibc or more tools. |
| Distroless | 20–80 MB | Production; no shell needed. |
| Full OS | 100 MB+ | Legacy or many system deps. |
Use multi-stage builds
Build in a stage that has compilers and build dependencies; copy only the artifact (binary, static files, node_modules for production) into a final stage with a minimal base. The final image does not contain the build toolchain or intermediate files.
# Build stage: full Node + dev deps
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Final stage: only runtime and built output
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production
USER node
CMD ["node", "dist/server.js"]
Combine RUN and clean package manager caches
Each RUN can add a layer. Combine commands in one RUN and remove cache and temporary files in the same layer so they do not increase image size:
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
For Alpine: RUN apk add --no-cache curl. For multi-line, use backslashes and clean up in the same RUN.
Use .dockerignore
The build context (current directory by default) is sent to the daemon. Exclude unneeded files with a .dockerignore so that COPY . . does not pull in .git, node_modules, docs, tests, or local env files. That speeds up builds and avoids accidentally copying secrets or large directories.
.git
node_modules
*.md
.env*
dist
coverage
Prefer COPY over ADD
COPY is explicit and does not do automatic unpacking or fetch from URLs. ADD can fetch from URLs and unpack tar; use it only when you need that. For local files, COPY is simpler and avoids surprises.
Install only production dependencies in the final image
In Node: use npm ci --only=production or npm prune --production so devDependencies are not in the image. In Python: use a virtualenv and install only what the app needs at runtime. In multi-stage builds, copy only the production dependency tree into the final stage.
Pin versions and use digest where appropriate
Pin base and dependency versions so builds are reproducible. For maximum reproducibility, pin the base image by digest (e.g. FROM node:20-alpine@sha256:...). That also makes it clear when the base has changed.
Summary: image sizing
| Practice | Why it helps |
|---|---|
| Small base (Alpine, slim, distroless) | Fewer MB per image. |
| Multi-stage build | Final image has no build tools or intermediate files. |
| Combine RUN + clean caches | No extra layer holding cache; smaller layers. |
| .dockerignore | Smaller context; no junk or secrets in layers. |
| COPY not ADD | Predictable; no accidental bloat from URLs or archives. |
| Production deps only | Fewer packages and less disk. |
Core concepts
| Term | Meaning |
|---|---|
| Image | Read-only template: OS layer + app + dependencies. Built from a Dockerfile or pulled from a registry. |
| Container | A running instance of an image. You can run many containers from the same image. |
| Dockerfile | Text file with instructions to build an image (e.g. FROM, RUN, COPY, CMD). |
| Registry | Storage for images (Docker Hub, Azure ACR, AWS ECR, etc.). |
Dockerfile example
Each instruction that modifies the filesystem adds a layer (see Docker image layers above). This example keeps dependency install in its own layer so that layer can be cached when only app code changes:
# Use an official runtime as base
FROM node:20-alpine
# Set working directory inside the container
WORKDIR /app
# Copy dependency list and install
COPY package*.json ./
RUN npm ci --only=production
# Copy application code
COPY . .
# Expose port and define default command
EXPOSE 3000
CMD ["node", "server.js"]
Build the image:
docker build -t my-app:latest .
Running containers
# Run in foreground (logs in terminal)
docker run -p 8080:3000 my-app:latest
# Run in background (detached)
docker run -d --name web -p 8080:3000 my-app:latest
# Run with environment variable
docker run -e NODE_ENV=production -p 8080:3000 my-app:latest
# Remove container when it stops
docker run --rm -p 8080:3000 my-app:latest
Essential commands
# List running containers
docker ps
# List all containers (including stopped)
docker ps -a
# List images
docker images
# Stop a container
docker stop web
# Remove a container
docker rm web
# Remove an image
docker rmi my-app:latest
# View logs
docker logs web
# Execute a command inside a running container
docker exec -it web sh
Docker Compose (multi-container)
For apps that need several services (app + database + cache), use a docker-compose.yml:
version: "3.9"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Run everything:
docker compose up -d
Summary
- Docker CLI is a thin client: it parses commands and talks to the Docker daemon over a Unix socket (or TCP via
DOCKER_HOST). The daemon (dockerd) owns all images, containers, networks, and volumes; it is configured via/etc/docker/daemon.jsonon Linux and runs as a system service or inside Docker Desktop’s VM. - Images are built from Dockerfiles (or pulled); containers are running instances of images.
- On Linux (e.g. Ubuntu), Docker runs natively (same kernel, namespaces, cgroups); on macOS and Windows, the daemon and Linux containers run inside a Linux VM or WSL 2.
- Networking: Containers use a bridge (default or user-defined) with private IPs; port publishing (-p) exposes container ports on the host; on user-defined networks (and Compose), containers resolve each other by name.
- Storage: The container filesystem is image layers + a writable layer (ephemeral). Use volumes for persistent data (e.g. DB); use bind mounts for host paths (e.g. dev code); use tmpfs for in-memory temporary data.
- Security: Run as non-root (USER), do not store secrets in images, use multi-stage builds, pin base tags, use read-only root FS and drop capabilities where possible, limit resources, and scan images for CVEs.
- Image sizing: Use small bases (Alpine, slim, distroless), multi-stage builds, combine RUN and clean caches, use .dockerignore, and install only production dependencies so images stay small and fast to pull.
- Use
docker buildanddocker runfor single services; use Docker Compose for multi-service setups. - Combine with CI/CD and orchestration (e.g. Kubernetes) to run containers at scale.
Comments