This is a structured introduction to Docker, from first principles to running a real multi-container application.

There are fourteen chapters, split into two halves.

Part 1. Understanding what Docker is and how it works.

  1. Introduction
  2. History and Motivation
  3. Technology Overview
  4. Installation and Setup
  5. Using Third-Party Images
  6. Container Data and Volumes
  7. The Demo Application

Part 2. Using it: building, running, securing, and orchestrating a real application.

  1. Building Container Images
  2. Container Registries
  3. Running Containers
  4. Container Security
  5. Interacting with Docker Objects
  6. Development Workflow
  7. Deploying Containers

1. Introduction

What is Docker?

Docker is a platform for packaging, shipping, and running applications in containers. Three verbs, three ideas.

  • Package. Bundle your application and everything it depends on into a single artifact.
  • Ship. Move that artifact between machines, environments, and clouds without changing it.
  • Run. Start it as an isolated, lightweight process that behaves the same wherever it runs.

If you remember nothing else, remember that triplet. The rest of this post is filling in the details.

Why should you care?

A few numbers that show why containers stopped being optional. Over half of organizations now run application containers in production, up from 41% just two years ago (CNCF Annual Survey, 2026 ). Container experience has moved from “nice to have” to an assumed skill on most DevOps job listings. Environment setup that used to take hours of “install this, then install that” is replaced with a single docker compose up.

The cost of learning Docker is small. The cost of not learning Docker, if you write or operate software in 2026, is large.


2. History and Motivation

“It works on my machine”

Every developer has lived this story. You write code on a Mac. You push it to a Linux server. Something breaks because of a difference between the two environments that nobody noticed. Hours disappear into debugging things that have nothing to do with your code.

Docker solves this by packaging the environment together with the code. The container that runs on your laptop is bit-for-bit identical to the one running in production. The “works on my machine” excuse goes away because every machine is, in effect, your machine.

The evolution of application deployment

Three eras, each lighter than the last.

  • 1990s, bare metal. One application per server. Wasteful and expensive: a beefy machine sat at single-digit utilization because deploying a second app onto it risked breaking the first.
  • 2000s, virtual machines. Many VMs per physical server, each running its own operating system. Better utilization but heavy. A VM is gigabytes on disk and takes a minute to start.
  • 2013 onward, containers go mainstream. The underlying Linux primitives (cgroups, namespaces) had existed for years, but Docker packaged them behind a developer-friendly workflow and made containers the default unit of packaging. Many containers per host, sharing one operating system kernel. Lightweight, fast, and isolated enough for most production workloads.

Bare metal vs VMs vs containers

A quick comparison of what changes across the three:

PropertyBare metalVirtual machinesContainers
Startup timeMinutes30 to 60 secondsMilliseconds
Image sizeFull serverGigabytesMegabytes
IsolationNoneFull OSProcess level
PortabilityLowMediumExcellent
OverheadHighHighMinimal

Containers don’t replace VMs; many production setups run containers inside VMs. They’re complementary tools, but containers are now the unit of packaging for most modern applications.

Docker’s two superpowers

Docker pays off in two phases of your work.

In development, docker compose up brings your entire stack (database, API, frontend) online in seconds. No “install Postgres first, then Redis, then Node” README dance. The same command works on every machine.

In deployment, the image you tested locally is the image that runs in production. Same bytes, same behavior. The road from your laptop to a cloud server stops being a sequence of surprises.


3. Technology Overview

Linux kernel features power containers

Containers aren’t magic. They’re built from two features that have existed in the Linux kernel for years.

Namespaces isolate what a process can see. Each container gets its own view of process IDs, the network stack, the filesystem, users, and hostnames. The process believes it’s the only thing running on the machine. Mentally, namespaces are the walls between containers.

Control groups (cgroups) limit what a process can use. CPU quotas, memory caps, disk I/O budgets, network bandwidth. Without cgroups, a runaway container could consume the entire host. With them, you can confidently run dozens on one machine. Mentally, cgroups are the resource budgets.

That’s the whole foundation. Namespaces decide what a container sees; cgroups decide what a container can use.

Containers vs virtual machines, side by side

A VM runs a full guest operating system per application. The hypervisor sits between hardware and guest OS, and each VM thinks it owns a complete machine.

A container shares the host’s kernel. There’s no guest OS, no hypervisor between your app and the kernel. The container runtime (Docker) is what marshals namespaces and cgroups for each container.

This is why containers boot in milliseconds and VMs boot in seconds: there’s nothing to boot. A container is just a process the kernel has been told to constrain.

Docker architecture

Three components you’ll interact with:

  1. Docker CLI. The docker command you type in your terminal. Sends requests to the daemon.
  2. Docker Daemon (dockerd). The long-running process that does the real work: building images, starting containers, managing networks and volumes.
  3. Docker Hub (or any registry). Where images live when they’re not on your machine. Public images, your team’s images, vendor images.

When you run docker run nginx, the CLI sends a REST request to the daemon, the daemon pulls the nginx image from a registry if it doesn’t already have it, then sets up a container from that image and starts it.

Key Docker objects

Four nouns you’ll use constantly.

  • Image. A read-only template. Filesystem plus metadata. Sits on disk doing nothing.
  • Container. A running (or stopped) instance of an image. Isolated process with its own writable filesystem layer.
  • Volume. Persistent storage attached to a container. Survives container restarts and deletions.
  • Network. Virtual network connecting containers. Containers on the same network find each other by service name.

These four are the entire vocabulary you need to talk about Docker in any meaningful way. Everything else is variations and combinations.


4. Installation and Setup

Installing Docker Desktop

For most beginners, the easiest path is Docker Desktop. It’s a single installer that gives you the daemon, the CLI, and a graphical interface for inspecting your containers.

  1. Go to docker.com and download Docker Desktop for your operating system.
  2. Run the installer. Windows gets an .exe, macOS gets a .dmg, Linux gets a .deb or .rpm.
  3. Launch Docker Desktop. You’ll see a whale icon in your menu bar or system tray when it’s ready.

On Linux, you can also install the Docker Engine directly without the desktop GUI. That’s a slightly more advanced path but uses fewer resources.

Your first container

The moment of truth:

# Verify the install
docker --version
docker info

# Run a tiny test image
docker run hello-world

# Try an interactive Ubuntu container (--rm cleans it up on exit)
docker run --rm -it ubuntu bash

hello-world is the smallest possible image. Docker pulls it, runs it, prints a message, and exits.

docker run --rm -it ubuntu bash is more interesting. The -i keeps stdin open, the -t allocates a terminal, --rm removes the container as soon as you exit (so it doesn’t sit around as a stopped container), and bash is the command to run inside the container. You get a shell prompt inside a fresh Ubuntu environment. Run ls, cat /etc/os-release, exit. You were just inside an Ubuntu machine that didn’t exist five seconds ago and won’t exist five seconds from now.


5. Using Third-Party Images

Docker Hub: the app store for containers

Docker Hub hosts over a million public images. nginx, postgres, redis, node, python, mysql, mongo. If a piece of software is worth running in a container, it’s almost certainly on Docker Hub already.

Two flavors of images to know:

  • Official images are maintained by Docker Inc. or the software vendor. They follow consistent conventions, get regular security updates, and are the right default for most use cases.
  • Community images are published by individuals or teams. Useful, but verify before trusting them in production: check the publisher, the download count, recent activity, and the Dockerfile if it’s available.

Pull and run

The basic workflow:

# Search Docker Hub
docker search nginx

# Pull an image without running it
docker pull nginx:latest

# Run nginx in the background, map host 8080 to container 80
docker run -d -p 8080:80 nginx

# See what's running
docker ps

# See everything, including stopped containers
docker ps -a

Visit http://localhost:8080 and you’ll see the nginx welcome page. You just installed and ran a production-grade web server in two commands.

The -d flag (detached) runs the container in the background. The -p flag publishes a port from the host to the container. We’ll come back to ports in chapter 10.


6. Container Data and Volumes

The ephemeral problem

Containers are disposable. Stop one, delete it, start a fresh one. That’s not a bug, it’s the philosophy. But it creates a question: what about data?

If a Postgres container writes to its internal filesystem and then you delete the container, the database goes with it. Same with uploaded files, logs you want to keep, caches you don’t want to rebuild.

Mentally: a container’s filesystem is like RAM. It’s local to that container, and it disappears with it. Volumes are like a hard disk. They persist.

Volume types

Three kinds. You’ll mostly use the first two.

  • Named volumes. Managed by Docker. You give them a name (pgdata), Docker decides where on disk they live. Portable, easy to back up, the right default for production data.
  • Bind mounts. Map a host directory directly into the container. Great in development because changes on your host show up immediately in the container. Less ideal in production because they couple containers to host filesystem layout.
  • tmpfs mounts. In memory only. Ultra-fast but not persistent. Useful for sensitive temporary data you don’t want touching disk.

Volume commands

# Create a named volume explicitly
docker volume create mydata

# Run Postgres with a named volume
docker run -v mydata:/var/lib/postgresql/data postgres:16-alpine

# Bind mount a Node project directory for development
# (run this from a directory with a valid package.json + "start" script)
docker run -v $(pwd):/app -w /app node:20 npm start

# List and inspect volumes
docker volume ls
docker volume inspect mydata

The -v flag has two forms. -v name:/path mounts a named volume. -v /host/path:/container/path is a bind mount. Docker figures out which one you meant by the first character of the first piece: starts with / or ., it’s a path; anything else, it’s a volume name.


7. The Demo Application

A 3-tier architecture

The example we use throughout is a small task tracker with three layers.

┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│    frontend     │      │      api        │      │      db         │
│ React + Vite    │ ───► │  Go 1.22        │ ───► │  Postgres 16    │
│ served by nginx │      │                 │      │  named volume   │
│ port 80 → 3000  │      │  port 8080      │      │  port 5432      │
└─────────────────┘      └─────────────────┘      └─────────────────┘

The frontend is a static site served by nginx. The API is a Go binary that exposes REST endpoints. The database is Postgres with a named volume for persistence.

Why this architecture?

Four reasons this shape keeps showing up in real applications:

  1. Separation of concerns. Each tier has one job. The frontend handles UI, the API handles business logic, the database handles storage.
  2. Independent containers. Each tier runs in its own container with its own language and runtime. The frontend can use Node tooling without the API needing any of it.
  3. Independent scaling. If the API gets hammered, scale only the API. No need to spin up extra frontends or databases.
  4. It’s the industry pattern. Most modern web applications start with some variation of this three-tier shape, even ones that grow into hundreds of microservices later.

We’ll use this app as the running example for the rest of the post.


8. Building Container Images

What is a Dockerfile?

A Dockerfile is a text file with step-by-step instructions for building an image. Think of it as a recipe. Each instruction is one step, and each step produces a layer in the final image.

The translation looks like this: Dockerfile is the recipe, docker build is the cooking, the resulting image is the finished dish.

Dockerfile anatomy

A generic Node example to introduce the directives. The demo app’s actual frontend Dockerfile appears in the multi-stage section below, with the same cache pattern adapted for the React+nginx setup.

The instructions you’ll see most:

# Start from a base image
FROM node:20-alpine

# Set working directory
WORKDIR /app

# Copy dependency files first (cache trick, more on this below)
COPY package*.json ./

# Install dependencies
RUN npm ci --omit=dev

# Copy application code
COPY . .

# Document which port the app uses
EXPOSE 3000

# Default command when the container starts
CMD ["node", "server.js"]

A quick tour:

  • FROM picks the base image. Everything else starts from here.
  • WORKDIR sets the directory subsequent commands run from.
  • COPY brings files from your machine into the image.
  • RUN executes a command during the build.
  • EXPOSE is documentation: it tells future readers which port the container listens on. It does not actually publish the port.
  • CMD declares what to run when the container starts. It does not execute during the build.
  • ENV sets environment variables. ARG declares build-time variables that don’t end up in the final image.

Image layers and build caching

Every Dockerfile instruction produces a layer. Docker caches layers aggressively: if an instruction and everything before it is unchanged, Docker reuses the cached layer instead of running the instruction again.

The implication is huge for build speed. The order of your Dockerfile matters.

Look at the Dockerfile above. package*.json is copied first, then npm ci. Then the application source is copied. Why?

Because package.json changes much less often than your source code. By copying it first and installing dependencies in a separate layer, you let Docker cache npm ci. When you edit a source file, only the final COPY layer is invalidated. The expensive npm ci stays cached and the rebuild is nearly instant.

The rule: put things that change rarely at the top, things that change often at the bottom.

Build commands

# Build, tagging the image as myapp:1.0
docker build -t myapp:1.0 .

# Force a full rebuild, ignoring the cache
docker build --no-cache -t myapp:1.0 .

# List local images
docker images

# Remove an image
docker rmi myapp:1.0

# Clean up unused images
docker image prune -a

The . at the end of docker build is the build context: the directory whose contents will be sent to the Docker daemon. Keep it small with a .dockerignore file so you’re not shipping node_modules and .git to the daemon on every build.

Multi-stage builds

A naive Dockerfile ships everything used during the build to production: compilers, test frameworks, dev dependencies, intermediate files. None of that needs to be in the running image.

Multi-stage builds solve this. You declare multiple FROM statements in one Dockerfile. Each starts a new stage. Only the final stage becomes the image you ship.

# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: serve
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The first stage compiles the React app with Node. The second stage starts fresh from a tiny nginx image and copies in only the built dist/ directory. The Node runtime, compilers, dev dependencies, and source code never enter the final image.

Result: a final image that can easily be ten times smaller than the naive single-stage version. Smaller image, faster pulls, less attack surface. The right tool runs at each stage: Node for building, nginx for serving.

If you need to customize nginx (gzip, SPA routing, caching headers), add a COPY nginx.conf /etc/nginx/conf.d/default.conf line to the final stage. The default config is fine for a simple static site, but real frontends usually want at least history-mode routing for client-side routers.


9. Container Registries

What is a registry?

A registry is to images what npm is to packages. A storage and distribution system.

Common registries you’ll meet:

  • Docker Hub (hub.docker.com). The default. Over a million public images. Anonymous pulls are rate-limited (100 per 6 hours, IP-based); a free account doubles that to 200. CI loops are the usual place this bites, so docker login even with a free account is worth doing.
  • GitHub Container Registry (ghcr.io). Free with GitHub, integrates well with GitHub Actions.
  • AWS ECR, Google Artifact Registry, Azure ACR. Managed registries from the big clouds. Best when you’re already deploying on that cloud.
  • Self-hosted (Harbor, Registry 2.0). Full control. Common in regulated environments where images can’t leave your network.

Push and pull

# Authenticate to Docker Hub
docker login

# Tag your image for the registry
docker tag myapp:1.0 username/myapp:1.0

# Push it
docker push username/myapp:1.0

# Pull from anywhere
docker pull username/myapp:1.0

# Pull from GitHub Container Registry
docker pull ghcr.io/org/myapp:latest

The image name carries the registry. If you write nginx, Docker assumes docker.io/library/nginx. If you write ghcr.io/org/myapp, Docker pulls from GitHub’s registry. Public images can be pulled without authentication; private ones can’t. Even for public pulls, though, logging in lifts your rate limits, so docker login is worth doing for any non-trivial workflow.


10. Running Containers

Essential docker run flags

A handful you’ll reach for constantly:

FlagWhat it does
-dRun in the background (detached)
-p 8080:80Map host port to container port
-e KEY=VALUESet an environment variable
-v name:/pathMount a volume
--name myappGive the container a friendly name
--rmRemove the container when it exits
--restart alwaysRestart the container if it stops
--network mynetAttach to a specific network

You’ll see these combined constantly. Something like docker run -d --name web -p 8080:80 --restart unless-stopped nginx is a typical “run nginx properly in the background” command.

Port mapping in one picture

The -p hostPort:containerPort flag forwards traffic from a port on the host to a port in the container. If you run docker run -d -p 8080:80 nginx, then:

  1. Your browser hits http://localhost:8080.
  2. The host’s port 8080 forwards into the container’s port 80.
  3. nginx, listening inside the container on port 80, serves the response.

Multiple containers can listen on the same internal port. They just get different host ports.

Environment variables and restart policies

Two important practical patterns.

Environment variables are how you configure a container without baking the configuration into the image:

docker run -e DB_HOST=postgres -e DB_PORT=5432 myapp
docker run --env-file .env myapp

The --env-file form reads variables from a file, which is useful when you have a lot of them. Important: never bake secrets into the image itself. Use environment variables, Docker secrets, or a secrets manager.

Restart policies control what Docker does if your container exits:

  • --restart no (default). Do nothing.
  • --restart on-failure. Restart only on non-zero exit codes.
  • --restart unless-stopped. Restart unless you manually stopped it. Survives host reboots.
  • --restart always. Always restart, even if you stopped it manually. Also survives host reboots.

For long-lived services, unless-stopped is usually what you want.


11. Container Security

Best practices in four ideas

Most of container security boils down to a few habits:

  1. Run as a non-root user. Add a USER directive in your Dockerfile. Most processes don’t need root inside the container. Limiting the user limits what an attacker can do if they compromise the process.
  2. Use minimal base images. Smaller image, smaller attack surface. node:20-alpine over node:20. Distroless images go further: no shell, no package manager, just your binary.
  3. Scan for vulnerabilities. Tools like docker scout and Trivy check your images for known CVEs before you ship.
  4. Never put secrets in images. Don’t use ENV or ARG for passwords. Use environment variables at run time, Docker secrets, or an external vault.

Running as a non-root user

The simplest Dockerfile pattern for running as non-root looks like this:

FROM node:20-alpine

# Create a system user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy files with correct ownership
COPY --chown=appuser:appgroup . .
RUN npm ci --omit=dev

# Switch to non-root
USER appuser

CMD ["node", "server.js"]

By default, containers run as root. The addgroup and adduser commands create a system user. The --chown flag on COPY makes sure your files are readable by that user. USER appuser switches the running user for everything that comes after.

If an attacker exploits a vulnerability in your app now, they’re a regular user inside the container. They can’t apk add more tools. They can’t write to system paths. They have to bring their own exploit for the kernel to escape, which is a much higher bar.

Distroless: minimal base and non-root in one move

For compiled languages, you can combine “minimal base” and “non-root” into a single, very tight final stage. Distroless images ship just enough to run a static binary: no shell, no package manager, no /bin/sh. Google publishes a nonroot variant that runs as UID 65532 by default.

# ---- build stage
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /api .

# ---- final stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]

The final image is a few megabytes, contains exactly one binary, runs as a non-root user, and has no shell for an attacker to drop into. The trade-off is that you can’t docker exec your way into a debugging session. There’s no shell to exec. For long-lived production services, that’s usually the right trade.


12. Interacting with Docker Objects

Essential commands for debugging

When something goes wrong inside a running container, these are the tools:

CommandPurpose
docker exec -it <id> bashOpen a shell inside the running container
docker logs -f <id>Stream live logs
docker inspect <id>Detailed JSON config
docker cp file.txt <id>:/app/Copy files in or out
docker statsLive CPU and memory usage
docker top <id>Processes inside the container

A typical debug session might look like this:

# Tail the logs
docker logs -f --tail 100 mycontainer

# Drop into a shell
docker exec -it mycontainer bash

# Inspect just the network settings
docker inspect mycontainer | jq '.[0].NetworkSettings'

# Copy logs out for offline analysis
docker cp mycontainer:/app/logs ./logs

# See live resource usage
docker stats --no-stream

A few gotchas worth knowing. Alpine images don’t have bash, only sh. Distroless images don’t have any shell at all, so docker exec won’t give you a prompt. And anything you write inside a container is lost when the container is removed (not just stopped), unless it’s in a volume.


13. Development Workflow

Docker Compose

So far we’ve talked about one container at a time. Real applications have several. Docker Compose lets you define a multi-container application in a YAML file and start the whole thing with one command.

What Compose gives you:

  • One file describes your entire stack.
  • docker compose up starts every service in the right order.
  • Services find each other by name on a private network.
  • docker compose down tears everything down cleanly.

docker-compose.yml anatomy

services:
  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - api

  api:
    build: ./api
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_USER: ${POSTGRES_USER:-tasks}
      DB_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
      DB_NAME: ${POSTGRES_DB:-tasksdb}
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}

volumes:
  pgdata:

A few things to notice:

  • services: declares each container in the stack.
  • build: says “build this service from a local Dockerfile.” image: says “use a published image.”
  • ports: maps a host port to a container port, same as docker run -p. Services without ports: (like api above) are still reachable from other services on the compose network. They just aren’t published to the host.
  • depends_on: controls start order. By default it does not wait for the service to be ready, only started. Add a healthcheck and condition: service_healthy if you need actual readiness gating.
  • environment: sets env vars for that service. The ${VAR:-default} syntax reads VAR from a .env file (which Compose loads automatically from the project root) and falls back to default if it’s not set. This keeps real secrets out of your compose file and out of git.
  • volumes: (the top-level block) declares named volumes used by services.

The magic moment: inside the api service, DB_HOST: db works because Compose puts every service on a shared network and Docker’s embedded DNS resolves service names. No IP addresses, no manual networking.

Compose commands

# Start everything in the background
docker compose up -d

# Tail logs from all services
docker compose logs -f

# Rebuild images and start
docker compose up -d --build

# Stop and remove everything
docker compose down

# Also remove volumes (destroys data)
docker compose down -v

# Run a command inside a service
docker compose exec api bash

docker compose up -d is the command you’ll type most often during development. It reads the compose file, builds any images that need building, creates the networks and volumes, and starts all the services.

Hot reload for development

One last trick. In development you don’t want to rebuild your image every time you edit a file. The pattern: bind-mount your source code into the container so the container sees your changes instantly.

services:
  api:
    build:
      context: ./api
      target: dev
    volumes:
      - ./api:/build
    command: ["air"]

This snippet shows only the fields that change for development; the environment: and depends_on: blocks from the main compose example above still apply. target: dev tells Compose to build only up to the dev stage of the api’s multi-stage Dockerfile. That stage is a heavier image with air installed and the Go toolchain available, separate from the slim distroless stage used in production. The bind mount keeps /build in sync with ./api on your host (matching the WORKDIR /build from the distroless build stage), and air recompiles the Go binary and restarts it whenever a .go file changes. You save in your editor; the container reacts in a second or two. No docker compose build between edits.

The same pattern works in any language: air for Go, nodemon for Node, watchexec as a language-agnostic option. The shape is always bind-mount the source, run a watcher as the entrypoint, and use a development-friendly image (not your distroless production stage).


14. Deploying Containers

Deployment options

A quick map of where to actually run containers in production.

  • Docker Swarm. Built into Docker. Simplest path from docker compose to multi-host production. Limited features compared to Kubernetes but easy to operate.
  • Kubernetes. The industry standard for serious container orchestration. Auto-scaling, self-healing, rolling updates, declarative everything. Steep learning curve, but it’s where most large container deployments live.
  • Managed container services. Google Cloud Run, AWS ECS, AWS Fargate, Azure Container Apps. You hand the cloud provider an image; they run it. Zero infrastructure to manage. Great default for most teams that don’t have a dedicated platform team.
  • Platform-as-a-Service. Railway, Render, Fly.io. Push code, get a URL. The fastest path from “I have a container” to “it’s on the internet.” Great for indie developers and prototypes.

If you’re just starting, a managed service like Cloud Run or a PaaS like Fly is usually the right call. Kubernetes pays off when you have many services and a team to operate it.


Key takeaways

If you skip everything else, these are the ideas that matter:

  1. Containers are portable, isolated environments for your application. Same image, dev to production.
  2. Images are blueprints (read-only). Containers are running instances of those blueprints.
  3. A Dockerfile is the recipe for an image. Layer order controls cache behavior, which controls build speed.
  4. Volumes give you persistence. Networks give you connectivity. Both are independent of any single container.
  5. Docker Compose defines multi-container applications in one YAML file. It’s how you go from running one container to running a system.
  6. Security gains come from a few small habits: non-root users, minimal base images, no secrets in images, vulnerability scanning.

What’s next

You’re past beginner. The next learning loops:

  • Compose plus development workflows. Bind mounts, hot reload, healthchecks, environment files. This is where most real day-to-day Docker usage lives.
  • Kubernetes. When one host isn’t enough. Pods, deployments, services, ingress. A whole second language built on top of containers.
  • CI/CD with containers. Build images in GitHub Actions, push to a registry, deploy automatically. The full lifecycle.
  • Image security. Distroless images, vulnerability scanning, signed images, software supply chain practices.

The Docker documentation is excellent. Docker Hub is the catalog. Play with Docker gives you a free playground in the browser if you want to experiment without installing anything.

You’re now equipped to read someone else’s Dockerfile, understand what every line is doing, and write your own with intent. That’s the bar to clear before you call yourself comfortable with Docker. From here on, it’s mostly practice.