what is docker?

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.

running containers

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'

managing containers

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'

managing images

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'

writing a dockerfile

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'

networking

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'

docker compose

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'

related topics

Ubuntu Terminal Commands — the shell fundamentals this whole workflow runs on top of.
Debugging: gdb, pdb & a General Method — the same `docker exec`-into-a-shell instinct, applied to debugging a running container.
Git Cheat Sheet — the source control a Dockerfile's COPY instructions usually pull from.
VS Code Remote Development — editing code that runs inside a container directly from your editor.

reference

docs.docker.com
Dockerfile reference
Docker Compose documentation