Docker packages an application together with everything it needs to run — code, runtime, system
libraries, configuration — into a single unit called a container, so it behaves the
same on your laptop, a teammate's laptop, and a production server.
The key idea that makes this cheap is that a container is not a virtual machine. A VM
virtualizes hardware and runs a full guest operating system (its own kernel) on top of a hypervisor,
which is why VMs are measured in gigabytes and take tens of seconds to boot. A container virtualizes
at the operating-system level instead: it shares the host machine's kernel, and is just an isolated,
resource-limited process (or group of processes) using Linux kernel features — namespaces (what
a process can see: its own filesystem, network interfaces, process list, hostname) and cgroups
(what a process can use: limits on CPU, memory, I/O). That's why containers are measured in
megabytes and start in a fraction of a second — there's no second kernel to boot.
Two terms to keep straight from the start:
- Image — a read-only template: your application plus its dependencies, built
once from a set of instructions (a Dockerfile) and stored as a stack of immutable layers.
- Container — a running (or stopped) instance created from an image, with a
thin writable layer of its own on top. You can start many independent containers from the same
image, the same way you can create many objects from one class.
Images are shared and distributed through a
registry — Docker Hub is the default
public one, though most teams eventually run or use a private registry (GHCR, ECR, GCR, Artifactory, ...).
installation via terminal
The official Docker Engine install on Ubuntu (Docker's own apt repository, not the older docker.io distro package, which lags behind):
sudo apt update && sudo apt install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update && sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER | | adds your user to the docker group so you can run docker without sudo — log out and back in (or run `newgrp docker`) for it to take effect |'dk1'
docker --version | | shows the installed Docker CLI version |'dk2'
docker version | | shows detailed client and server (daemon) version and API info |'dk3'
docker info | | shows system-wide information: number of containers/images, storage driver, resource totals |'dk4'
docker run hello-world | | pulls a tiny test image and runs it — the standard "is Docker working?" sanity check |'dk5'
architecture: client, daemon & runtime
The docker command you type is a thin CLI client. It talks over a REST API
to dockerd, the background daemon that does the actual work: pulling images, building
them, and managing containers, networks, and volumes. Under the hood, dockerd delegates to
containerd (a separate container-lifecycle daemon) which in turn uses runc,
a low-level tool that creates the namespaces/cgroups and actually starts the container process according
to the OCI (Open Container Initiative) runtime spec. You'll rarely touch containerd or runc directly, but
knowing they're there explains why other tools (Kubernetes, Podman) can reuse the same layers of this
stack without going through Docker at all.
docker run <image> | | creates and starts a new container from an image, pulling it first if it isn't cached locally |'dk6'
docker run -it ubuntu bash | | -i keeps stdin open, -t allocates a pseudo-terminal — together they give you an interactive shell inside the container |'dk7'
docker run -d --name web -p 8080:80 nginx | | -d runs detached (in the background), --name gives the container a friendly name, -p publishes a port |'dk8'
-p <host_port>:<container_port> | | maps a port on the host to a port inside the container; without it, the container's ports aren't reachable from outside |'dk9'
-e <VAR>=<value> | | sets an environment variable inside the container; repeat -e for more than one |'dk10'
-v <host_path>:<container_path> | | mounts a host directory (or named volume) into the container — see volumes below |'dk11'
--rm | | automatically removes the container's filesystem when it exits — ideal for short-lived, throwaway runs |'dk12'
--restart unless-stopped | | restarts the container automatically after a crash or daemon restart, unless you stopped it yourself |'dk13'
docker run --memory 512m --cpus 1 <image> | | caps the container to 512MB of memory and 1 CPU core, enforced by cgroups |'dk14'
docker ps | | lists running containers |'dk15'
docker ps -a | | lists all containers, including stopped ones |'dk16'
docker stop <container> | | sends SIGTERM, then SIGKILL after a grace period, to stop a running container cleanly |'dk17'
docker start <container> | | starts a previously stopped container again (reuses its existing filesystem state) |'dk18'
docker restart <container> | | stops then starts a container |'dk19'
docker rm <container> | | deletes a stopped container permanently; add -f to force-remove one that's still running |'dk20'
docker logs <container> | | shows a container's stdout/stderr output |'dk21'
docker logs -f <container> | | follows the log output live, like tail -f |'dk22'
docker exec -it <container> bash | | opens an interactive shell inside an already-running container — the main way to poke around a live container |'dk23'
docker inspect <container> | | dumps full JSON metadata: mounted volumes, network settings, environment variables, resource limits |'dk24'
docker stats | | shows live CPU, memory, network, and I/O usage per container |'dk25'
docker cp <container>:<path> <host_path> | | copies a file out of (or, reversed, into) a container's filesystem |'dk26'
docker images (or docker image ls) | | lists locally stored images |'dk27'
docker pull <image>:<tag> | | downloads an image from a registry without running it. Omitting the tag defaults to :latest, which is just a convention, not "the newest version" guaranteed |'dk28'
docker rmi <image> | | deletes a local image (fails if a container still references it) |'dk29'
docker tag <image> <new_name>:<tag> | | gives an existing image an additional name/tag, commonly used before pushing to a registry under your own namespace |'dk30'
docker login | | authenticates the CLI against a registry (Docker Hub by default) so you can push |'dk31'
docker push <image>:<tag> | | uploads a locally tagged image to a registry |'dk32'
docker history <image> | | lists the layers that make up an image and the command that created each one |'dk33'
docker build -t <name>:<tag> . | | builds an image from a Dockerfile in the current directory (the "build context") and tags it |'dk34'
A Dockerfile is a plain-text recipe: each instruction produces one new, cached layer on top of the
previous one. A minimal example for a small Python app:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
FROM <image>:<tag> | | the base image everything else is built on top of — must be the first instruction (ARG excepted) |'dk35'
WORKDIR <path> | | sets the working directory for every instruction after it, creating the directory if needed |'dk36'
COPY <src> <dest> | | copies files from the build context into the image. Prefer this over ADD unless you specifically need ADD's extra behavior |'dk37'
ADD <src> <dest> | | like COPY, but can also auto-extract local tar archives and fetch remote URLs — both surprising side effects, which is why COPY is usually the safer default |'dk38'
RUN <command> | | executes a command at build time and commits the result as a new layer (e.g. installing packages) |'dk39'
ENV <VAR>=<value> | | sets an environment variable available at both build time and in every container run from the resulting image |'dk40'
ARG <name>=<default> | | defines a build-time-only variable, passed with docker build --build-arg — not present in the final container |'dk41'
EXPOSE <port> | | documents which port the container listens on — purely informational, it does not actually publish the port (-p at docker run does that) |'dk42'
USER <user> | | switches to a non-root user for every instruction after it (and for the running container) — important for security, see best practices below |'dk43'
VOLUME <path> | | marks a path as holding externally managed data, causing Docker to create an anonymous volume there if none is mounted explicitly |'dk44'
LABEL <key>=<value> | | attaches arbitrary metadata to the image (maintainer, version, git commit, ...) |'dk45'
HEALTHCHECK CMD <command> | | tells Docker how to periodically check whether the container is actually healthy, not just running (surfaced in docker ps as healthy/unhealthy) |'dk46'
CMD ["executable", "arg1"] | | the default command run when the container starts — easily overridden by arguments passed to docker run |'dk47'
ENTRYPOINT ["executable"] | | the fixed command that always runs; arguments passed to docker run (or CMD's own arguments, if both are set) are appended to it instead of replacing it |'dk48'
CMD and ENTRYPOINT are easy to mix up. With only CMD set, docker run image echo hi replaces the
whole default command with echo hi. With only ENTRYPOINT set to ["ping"], that same
command line instead runs as ping echo hi — the arguments are appended, not substituted.
Using both together is the common pattern for CLI-style images: ENTRYPOINT fixes the binary, CMD supplies
default arguments that a caller can still override.
layer caching & .dockerignore
Every instruction in a Dockerfile is cached as its own layer. On a rebuild, Docker reuses a cached layer
as long as the instruction's text and its inputs (the copied files, for COPY/ADD) haven't changed —
but the moment one layer's cache is invalidated, every layer after it must be rebuilt too, even
if they wouldn't otherwise have changed. That's why the example Dockerfile above copies
requirements.txt and installs dependencies before copying the rest of the application
code: source code changes on every commit, but dependencies rarely do, so ordering it this way means a
typical rebuild reuses the (slow) dependency-install layer and only re-runs the (fast) final copy.
.dockerignore | | a file, next to the Dockerfile, listing paths to exclude from the build context — same syntax and purpose as .gitignore. Keeps .git, node_modules, and secrets out of the image and speeds up the build by shrinking what gets sent to the daemon |'dk49'
docker build --no-cache -t <name> . | | forces a full rebuild, ignoring the layer cache entirely |'dk50'
volumes & data persistence
A container's writable layer disappears the moment the container is removed — anything written
there (a database's data files, uploaded content) is lost with it. Three ways to persist data outside
that layer:
- Named volumes — storage that Docker creates and manages itself (under
/var/lib/docker/volumes on Linux). The default choice for most persistent data: portable, and not
tied to any particular host directory layout.
- Bind mounts — a specific path on the host filesystem, mapped directly into
the container. Useful for local development (edit code on the host, see it live in the container) or
when you need a known host path.
- tmpfs mounts — storage that lives only in host memory, never written to
disk, and vanishes when the container stops. Useful for secrets or scratch space you don't want
persisted anywhere (Linux only).
docker volume create <name> | | creates a named volume |'dk51'
docker volume ls | | lists volumes |'dk52'
docker volume inspect <name> | | shows where a volume actually lives on disk and what's using it |'dk53'
docker volume rm <name> | | deletes a volume (fails if it's still attached to a container) |'dk54'
docker run -v mydata:/var/lib/data <image> | | mounts named volume "mydata" into the container at /var/lib/data (created automatically if it doesn't exist yet) |'dk55'
docker run -v $(pwd):/app <image> | | bind-mounts the current host directory into the container at /app |'dk56'
docker run --mount type=volume,source=mydata,target=/data <image> | | the more explicit, self-documenting alternative to -v, recommended by Docker's own docs for clarity in scripts |'dk57'
Every container gets network access through a Docker network. A common gotcha: on the default
bridge network, containers can only reach each other by IP address. Create your own
user-defined bridge network instead, and Docker gives every container on it automatic DNS resolution
by container name — the standard way multi-container apps talk to each other without hardcoding IPs.
docker network ls | | lists networks |'dk58'
docker network create <name> | | creates a user-defined bridge network |'dk59'
docker run --network <name> --name db <image> | | attaches a container to that network; other containers on the same network can now reach it at hostname "db" |'dk60'
docker network connect / disconnect <network> <container> | | attaches or detaches a running container from a network without restarting it |'dk61'
docker run --network host <image> | | shares the host's network stack directly, skipping Docker's network isolation and port mapping entirely (Linux only) |'dk62'
docker run --network none <image> | | gives the container no network access at all |'dk63'
Real applications are usually more than one container — an API, a database, a cache. Compose lets
you describe the whole stack declaratively in one
docker-compose.yml file (services, the
networks and volumes they share, environment variables) instead of typing out long docker run commands
by hand.
services:
web:
build: .
ports:
- "8000:8000"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
docker compose up | | builds (if needed) and starts every service defined in docker-compose.yml |'dk64'
docker compose up -d | | same, detached in the background |'dk65'
docker compose down | | stops and removes the containers and networks Compose created (add -v to also remove volumes) |'dk66'
docker compose ps | | lists the status of every service in the project |'dk67'
docker compose logs -f <service> | | follows the logs of one service (omit the name to follow all of them) |'dk68'
docker compose build | | rebuilds the images for services that have a "build:" key |'dk69'
docker compose exec <service> bash | | opens a shell inside a running service's container |'dk70'
Note the space: docker compose (a plugin bundled with modern Docker installs) is the current,
actively developed tool. The old standalone docker-compose (hyphenated, a separate Python
binary) is the deprecated predecessor — if a tutorial uses the hyphenated form, the space form is
almost always the drop-in replacement today.
advanced: multi-stage builds & image size
A naive Dockerfile often ships the entire build toolchain (compilers, dev headers, package caches)
inside the final image, when the running application only needs the compiled output. A multi-stage
build fixes this: use one stage to build, and copy only the finished artifact into a clean, minimal
final stage.
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /out/app .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
The final image here contains nothing but the compiled binary and a minimal runtime base — no Go
toolchain, no shell, no package manager. That's both smaller (faster to pull and start) and a much
smaller attack surface (there's barely anything in it for an attacker to exploit even after a compromise).
docker build --target <stage_name> -t <name> . | | builds only up through a named intermediate stage — handy for a debug build that stops before the slim final stage |'dk71'
docker buildx build --platform linux/amd64,linux/arm64 -t <name> --push . | | builds (and pushes) a multi-architecture image in one command, using the buildx plugin |'dk72'
advanced: security & cleanup
| | By default, a process inside a container runs as root (UID 0) — the same root as on the host, if a container-escape vulnerability is ever found. Add a USER instruction in the Dockerfile so containers run as an unprivileged user, the same way you wouldn't run a normal server process as root on bare metal. |'dk73'
docker run --read-only <image> | | mounts the container's root filesystem read-only, so a compromised process can't write anything outside its explicitly mounted volumes |'dk74'
docker scout cves <image> | | scans a local image for known vulnerabilities in its dependencies and base layers (bundled with modern Docker Desktop/Engine) |'dk75'
docker system df | | shows how much disk space images, containers, volumes, and the build cache are using |'dk76'
docker container prune | | removes all stopped containers |'dk77'
docker image prune | | removes dangling images (untagged layers left behind by rebuilds) |'dk78'
docker system prune -a --volumes | | aggressively removes every unused container, image, network, and volume — frees the most space, but is destructive: double-check nothing you need is only "unused" because it's stopped |'dk79'