DEV Community

Cover image for Docker, From Zero: What It Actually Solves, and How to Actually Use It
surajrkhonde
surajrkhonde

Posted on

Docker, From Zero: What It Actually Solves, and How to Actually Use It

Nephew has heard "just Dockerize it" a hundred times at work and nodded along without really knowing what that meant. Uncle sits him down for a proper, from-scratch walkthrough — no forced analogy this time, just clear explanations, one small piece at a time, with real commands you can actually type.


Part 1: The Real Problem Docker Solves (It's Not Just "Works on My Machine")

👦 Nephew: Uncle, everyone says Docker fixes "it works on my machine" problems. But what does that actually mean, in detail?

👨‍🦳 Uncle: That phrase is the symptom people quote, but the real disease underneath it is bigger, and worth understanding properly. Let's list the actual, concrete problems:

Problem 1 — Dependency conflicts on one machine.
Say Project A needs Node.js version 16, and Project B, on the exact same laptop, needs Node.js version 20. Without Docker, you'd have to juggle multiple Node versions manually (using something like nvm), and it only gets messier once you add databases, system libraries, and specific OS-level tools into the mix.

Problem 2 — Setup instructions that rot over time.
"Install Postgres 14, set this config flag, install these three system packages, make sure your OS is Ubuntu 22.04..." — these instructions are correct today and quietly wrong six months from now, once package versions move on. New team members lose entire days just getting a project running locally.

Problem 3 — "It ran on my laptop" vs. "it needs to run on a server."
Your laptop might be macOS, the production server is Linux, and subtle differences between them (file paths, installed library versions, default configurations) cause things to behave differently in ways that are genuinely hard to track down.

👦 Nephew: So the real problem is: an application never travels alone. It always drags its entire environment along with it — and that environment is fragile and hard to reproduce.

👨‍🦳 Uncle: Exactly. Docker's actual job is: package your application together with its entire environment — the exact runtime, exact dependencies, exact configuration — into one single, self-contained unit that behaves identically no matter where it's run. Not "hopefully behaves the same." Identically. That's the real problem, and that's the real fix.


Part 2: What Docker Actually Is, Under the Hood

👨‍🦳 Uncle: Let's open the hood before we run a single command, because typing commands blindly is how people end up confused six months in.

Docker has a few key pieces working together:

You type a command (e.g., docker run redis)
        │
        v
DOCKER CLIENT — the "docker" command itself, just a messenger
        │
        v
DOCKER DAEMON (dockerd) — a background process that does the
REAL work: building images, starting containers, managing
networks and storage. This is the actual engine.
        │
        v
containerd + runc — lower-level components that actually create
and run containers using Linux kernel features (namespaces for
isolation, cgroups for resource limits)
        │
        v
Your Linux kernel — the real operating system underneath everything
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So when I type docker run, I'm not directly running anything — I'm just sending a request to this background daemon?

👨‍🦳 Uncle: Exactly. The docker command you type is just a thin messenger. The daemon (dockerd) is the actual worker that receives that request and does everything — pulling images, allocating resources, starting the container. This distinction matters, because "is Docker running" really means "is the daemon running," which we'll check properly in Part 4.

Images vs. Containers — The One Distinction That Clears Up Most Confusion

👦 Nephew: People say "image" and "container" almost interchangeably. Are they the same thing?

👨‍🦳 Uncle: No, and this distinction alone clears up a huge amount of confusion. An image is a read-only blueprint — a packaged snapshot of your application, its dependencies, and its configuration, sitting on disk, not running. A container is a running instance created from that image — the actual live process, with its own writable layer on top.

IMAGE (blueprint, not running)
   │
   │ "docker run" creates a running instance from it
   v
CONTAINER (a live, running process, based on that image)
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So I could start five containers from the same one image?

👨‍🦳 Uncle: Exactly — five independent, running instances, all built from the same underlying blueprint, each with their own isolated running state, but sharing the same read-only image layers underneath for efficiency.


Part 3: Installing Docker on Ubuntu

👨‍🦳 Uncle: Let's actually get it installed. On Ubuntu, the officially recommended path looks like this:

# 1. Update your package list and install a few prerequisites
sudo apt-get update
sudo apt-get install ca-certificates curl

# 2. Add Docker's official GPG key (verifies packages are genuinely from Docker)
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# 3. Add Docker's official repository to your package sources
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 4. Update again, now that Docker's repo is added, then install
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: That's a lot of steps just for an install.

👨‍🦳 Uncle: It is, and it's worth it precisely because it verifies you're installing genuine Docker packages, kept up to date through your normal package manager going forward. One more step that trips up almost every beginner:

# By default, Docker commands need "sudo" every time — annoying.
# Add your user to the "docker" group to fix that:
sudo usermod -aG docker $USER

# Then log out and back in (or restart your terminal) for it to take effect
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: And after that, docker run just works without sudo every time?

👨‍🦳 Uncle: Exactly — your user is now allowed to talk to the daemon directly, without elevated permissions for every single command.


Part 4: The Daemon — Checking If Docker Is Actually Running

👦 Nephew: You mentioned the daemon earlier. How do I actually check if it's running, and what happens if it's not?

👨‍🦳 Uncle: The daemon (dockerd) is a background service — much like any other system service on Linux (think of how your web server or database might run continuously in the background). It needs to be running before any Docker command will actually work.

# Check whether the Docker service (the daemon) is active
$ sudo systemctl status docker

● docker.service - Docker Application Container Engine
   Loaded: loaded
   Active: active (running) since ...
Enter fullscreen mode Exit fullscreen mode
# If it's not running, start it
$ sudo systemctl start docker

# And to make sure it starts automatically every time you boot your machine
$ sudo systemctl enable docker
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: What if I run a Docker command while the daemon is stopped?

👨‍🦳 Uncle: You'll get a clear complaint, something like:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Is the docker daemon running?
Enter fullscreen mode Exit fullscreen mode

That error is precisely confirming the architecture from Part 2 — the CLI is just a messenger; if there's no daemon listening on the other end, the message has nowhere to go.

A quick general sanity check, once it's running:

$ docker version      # shows client AND daemon (server) versions
$ docker info          # detailed info about the running daemon itself
$ docker ps            # lists currently RUNNING containers (empty if none yet)
Enter fullscreen mode Exit fullscreen mode

Part 5: Running Your First Real Thing — A Database in Docker

👨‍🦳 Uncle: Theory's done. Let's actually run something useful — a Redis database, without installing Redis on your machine at all.

$ docker run -d --name my-redis -p 6379:6379 redis
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Let's break that down piece by piece.

👨‍🦳 Uncle: Gladly:

Part Meaning
docker run Create and start a new container
redis The image to use — Docker automatically downloads ("pulls") it from Docker Hub if you don't already have it locally
-d "Detached" — run in the background, don't block your terminal
--name my-redis Give this container a friendly name, instead of a random generated one
-p 6379:6379 Map port 6379 on your machine to port 6379 inside the container (we'll go deep on this in Part 6)
# Confirm it's actually running
$ docker ps

CONTAINER ID   IMAGE   STATUS         PORTS                    NAMES
a1b2c3d4e5f6   redis   Up 5 seconds   0.0.0.0:6379->6379/tcp   my-redis
Enter fullscreen mode Exit fullscreen mode
# See what the container itself is printing (its logs)
$ docker logs my-redis

# Actually get a shell INSIDE the running container, to poke around
$ docker exec -it my-redis redis-cli
127.0.0.1:6379>
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So my actual machine never had Redis "installed" in the traditional sense — it's living entirely inside that container?

👨‍🦳 Uncle: Entirely — and if you docker stop my-redis and docker rm my-redis, every trace of it vanishes from your system, cleanly, with nothing left behind to manually uninstall. That disposability is a genuine feature, not a limitation — though it does raise an important question we need to solve next: what happens to your actual data when the container disappears?


Part 6: Ports — Why a Container Only Exposes What You Explicitly Allow

👨‍🦳 Uncle: Let's slow down on that -p 6379:6379 flag, because understanding it properly avoids a lot of beginner confusion later.

A running container has its own isolated network space, completely separate from your actual machine's network. Redis, running inside the container, is listening on port 6379 — but that's port 6379 inside the container's own private network, not automatically reachable from your machine at all.

Your Machine (host)                    Container (isolated network)
                                             │
   Port 6379 on YOUR machine? -----X         │
   NOT connected to anything                 Redis listening on port 6379,
   by default!                               but INSIDE the container only
Enter fullscreen mode Exit fullscreen mode

The -p hostPort:containerPort flag is what explicitly creates a bridge between the two:

-p 6379:6379
    │      │
    │      └── Port INSIDE the container that the app is actually listening on
    └── Port on YOUR actual machine that gets forwarded to it
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So if I don't add -p, the container runs, Redis works fine internally, but I genuinely can't reach it from my own machine at all?

👨‍🦳 Uncle: Exactly — and that's a deliberate, useful safety default, not an oversight. A container only exposes exactly what you explicitly tell it to. If a container is running an internal tool that only other containers need to talk to, and never your actual host machine directly, you simply don't map a port for it at all — reducing what's reachable to the bare minimum needed.

# You can also map to a DIFFERENT port on your host, if 6379 is
# already taken by something else on your machine
$ docker run -d -p 7000:6379 redis
# Now YOUR machine's port 7000 reaches the container's internal 6379
Enter fullscreen mode Exit fullscreen mode

Part 7: Networks — How Containers Talk to Each Other

👦 Nephew: What if I have two containers — my Node app and Redis — and I want them to talk to each other, not just me talking to Redis from outside?

👨‍🦳 Uncle: This is exactly what a Docker network solves. By default, containers exist somewhat isolated from each other. But you can create a shared network and attach multiple containers to it — once they're on the same network, they can find and talk to each other by container name, automatically, without you manually tracking IP addresses.

# Create a custom network
$ docker network create my-app-network

# Run Redis attached to that network
$ docker run -d --name my-redis --network my-app-network redis

# Run your Node app attached to the SAME network
$ docker run -d --name my-app --network my-app-network -p 3000:3000 my-node-image
Enter fullscreen mode Exit fullscreen mode

Now, inside your Node app's code, you can connect to Redis using the container's name as if it were a hostname:

// Inside the Node app container, this just works —
// Docker's internal DNS resolves "my-redis" to the right container
const redis = require('redis');
const client = redis.createClient({ url: 'redis://my-redis:6379' });
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So my-redis isn't a real internet domain — it's a name Docker itself understands, only within that shared network?

👨‍🦳 Uncle: Exactly — Docker runs its own small internal DNS system for containers on the same custom network, resolving container names to their internal addresses automatically. This is precisely why multi-container setups (which we'll simplify further in Part 9) don't need you to hunt down and hardcode IP addresses anywhere.


Part 8: Volumes — Making Data Actually Survive

👦 Nephew: Back to the question from Part 5 — if I remove my Redis container, what happens to any data that was stored in it?

👨‍🦳 Uncle: Gone, completely, unless you've explicitly set up a volume. By default, anything a container writes lives in its own temporary, disposable writable layer — the moment the container is removed, that layer, and everything in it, is deleted along with it. This is intentional; containers are meant to be disposable, replaceable at any moment. Your actual data, however, usually shouldn't be.

A volume is a piece of storage managed by Docker itself, existing outside any single container's own disposable layer. You mount it into a container at a specific folder path — and even if that container is deleted and a brand-new one is started in its place, the volume, and everything inside it, is still there.

# Create a named volume
$ docker volume create my-redis-data

# Run Redis, mounting that volume to the folder where Redis actually
# stores its data internally
$ docker run -d --name my-redis -v my-redis-data:/data redis
Enter fullscreen mode Exit fullscreen mode
WITHOUT a volume:
  Container removed → all data inside it is gone forever

WITH a volume:
  Container removed → volume (and its data) SURVIVES independently
  New container started, same volume attached → picks up
  right where the old one left off, data intact
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So the volume is basically stored outside the container's own lifecycle entirely?

👨‍🦳 Uncle: Precisely — it's managed by Docker separately, and you can list it, inspect it, even back it up, independently of whether any container is currently using it at all.

$ docker volume ls              # see all volumes on your machine
$ docker volume inspect my-redis-data   # see exactly where it lives on disk
Enter fullscreen mode Exit fullscreen mode

There's a second, related concept worth knowing: a bind mount, where instead of letting Docker manage the storage location, you point directly to a specific folder on your own machine:

$ docker run -d -v /home/you/redis-data:/data redis
Enter fullscreen mode Exit fullscreen mode

Use named volumes (like the first example) for most cases — Docker manages the actual location for you. Use bind mounts specifically when you need direct, transparent access to the files from your own machine too — for example, live-editing source code that a container is running.


Part 9: The Dockerfile — Packaging Your Own Application

👨‍🦳 Uncle: So far we've run existing images — Redis, built by someone else. Now let's package your own application. The instructions for building your own image live in a file literally named Dockerfile.

# Dockerfile

# Start from an existing base image — don't build Node.js from scratch
FROM node:20

# Set the working directory INSIDE the container
WORKDIR /app

# Copy just the dependency files first (explained below)
COPY package*.json ./

# Install dependencies
RUN npm install

# Now copy the rest of your actual application code
COPY . .

# Document which port this container will listen on (informational —
# doesn't actually publish it; you still need -p at runtime for that)
EXPOSE 3000

# The command that runs when the container actually starts
CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why copy package*.json separately, before copying the rest of the code?

👨‍🦳 Uncle: A genuinely useful performance trick. Docker builds images in layers, and caches each layer — if a layer hasn't changed since the last build, Docker reuses the cached version instead of redoing that step. By copying just your dependency files first and running npm install right after, that layer only gets rebuilt when your dependencies actually change — not every single time you edit a line of your application code. Copying everything at once, in one step, would throw away that caching benefit constantly.

# Build an image FROM this Dockerfile, tagging it with a name
$ docker build -t my-node-app .

# Run a container from the image you just built
$ docker run -d -p 3000:3000 my-node-app
Enter fullscreen mode Exit fullscreen mode

Part 10: docker-compose — Running Multiple Services Together

👦 Nephew: This is the part I really need. Say my project needs my Node app, Redis, and MongoDB, all running together, each needing their own port, their own configuration. Running three separate long docker run commands every time feels unmanageable.

👨‍🦳 Uncle: Exactly the problem docker-compose solves. Instead of typing out long individual commands, you describe your entire multi-container setup in one file, written in YAML — a plain-text format that uses indentation to show structure, instead of curly braces or heavy punctuation.

# docker-compose.yml

version: "3.8"

services:
  app:
    build: .                    # build from the Dockerfile in this folder
    ports:
      - "3000:3000"
    depends_on:
      - redis
      - mongo
    environment:
      - REDIS_URL=redis://redis:6379
      - MONGO_URL=mongodb://mongo:27017/mydb

  redis:
    image: redis
    ports:
      - "6379:6379"

  mongo:
    image: mongo
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Let's go through it piece by piece.

👨‍🦳 Uncle: Happily:

  • services: — each entry underneath is one container you want running. Here, we have three: app, redis, and mongo.
  • build: . — for your own app, build it from the Dockerfile sitting in the current folder (Part 9). For Redis and Mongo, we just use image: directly, since we're not building those ourselves — just pulling ready-made images.
  • ports: — same hostPort:containerPort mapping from Part 6, just written in YAML form instead of a -p flag.
  • environment: — configuration values passed into the container, here telling your app exactly which internal hostnames to use for Redis and Mongo.
  • depends_on: — tells Compose to start redis and mongo before starting app, since your app likely needs them already running.
  • volumes: at the bottom — declares a named volume (Part 8) for MongoDB's data, so restarting your whole stack doesn't wipe your database.

👦 Nephew: And notice — inside environment, it says redis://redis:6379, using redis as a hostname?

👨‍🦳 Uncle: Exactly the network trick from Part 7 — Compose automatically creates a shared network for all the services defined in one file, and each service can reach the others using its service name as the hostname, with zero extra setup required.

# Start EVERYTHING defined in the file, in the right order
$ docker-compose up

# Run it in the background (detached)
$ docker-compose up -d

# Stop and remove everything it started
$ docker-compose down

# Stop everything, AND remove the volumes too (careful — deletes data!)
$ docker-compose down -v
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So one command, docker-compose up, replaces three separate docker run commands, gets the networking right automatically, and I never had to manually figure out IP addresses for anything?

👨‍🦳 Uncle: Exactly the entire point of Compose — describe your whole multi-container setup once, in one readable file, and let Docker handle wiring it all together correctly, every single time, identically.


Part 11: Commands Worth Actually Memorizing

👨‍🦳 Uncle: Let's lock in the ones you'll genuinely use constantly.

Command What It Does
docker ps List currently running containers
docker ps -a List ALL containers, including stopped ones
docker images List images you have locally
docker run <image> Create and start a new container from an image
docker stop <name> Gracefully stop a running container
docker rm <name> Remove a stopped container
docker rmi <image> Remove an image
docker logs <name> View a container's output/logs
docker exec -it <name> bash Open an interactive shell inside a running container
docker build -t <tag> . Build an image from a Dockerfile in the current folder
docker volume ls List volumes
docker network ls List networks
docker-compose up -d Start everything defined in docker-compose.yml, in the background
docker-compose down Stop and remove everything started by Compose
docker system prune Clean up unused containers, images, and networks to free disk space

Part 12: Putting It All Together, From Scratch

👨‍🦳 Uncle: Let's walk the complete path, start to finish, as if you were doing this for the very first time on a brand-new machine.

1. INSTALL DOCKER (Part 3)
   sudo apt-get install docker-ce docker-ce-cli containerd.io ...
   sudo usermod -aG docker $USER

2. CONFIRM THE DAEMON IS RUNNING (Part 4)
   sudo systemctl status docker
   docker ps    ← should return an empty list, no errors

3. WRITE YOUR DOCKERFILE (Part 9)
   FROM node:20
   WORKDIR /app
   COPY package*.json ./
   RUN npm install
   COPY . .
   EXPOSE 3000
   CMD ["npm", "start"]

4. WRITE YOUR docker-compose.yml (Part 10)
   Define your app, redis, and mongo services together,
   with ports, volumes, and environment variables

5. BRING EVERYTHING UP
   docker-compose up -d

6. VERIFY
   docker ps                    ← see all three containers running
   docker logs <app-container>  ← check your app started correctly
   curl localhost:3000          ← confirm the port mapping actually works

7. WHEN YOU'RE DONE FOR THE DAY
   docker-compose down          ← stops everything, but your MongoDB
                                   volume (Part 8) survives untouched

8. NEXT TIME
   docker-compose up -d         ← same command, same result, every time,
                                   on this machine or any other one
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So the entire promise of Docker really does come down to: write this setup once, and it behaves identically every single time, on any machine that has Docker installed?

👨‍🦳 Uncle: That's the whole promise, delivered honestly. Not "probably works the same." Actually the same — same base image, same dependency versions, same configuration, every single time, whether it's your laptop, a teammate's machine, or a production server somewhere you've never even logged into directly.


Key Takeaways

  • The real problem Docker solves is environment fragility — dependency conflicts, drifting setup instructions, and differences between machines — not just "works on my machine" as a punchline.
  • The Docker daemon (dockerd) does the actual work; the docker CLI is just a messenger sending it requests. Check systemctl status docker if commands aren't working.
  • An image is a read-only blueprint; a container is a running instance of that blueprint — many containers can be started from one image.
  • Ports are not automatically exposed — a container's internal port is only reachable from your machine if you explicitly map it with -p hostPort:containerPort.
  • A Docker network lets multiple containers find and talk to each other by name, using Docker's own internal DNS — no manual IP tracking needed.
  • A volume is storage that lives outside any single container's disposable lifecycle — data survives even if the container using it is removed and replaced.
  • A Dockerfile describes how to build your own image, layer by layer — ordering instructions deliberately (dependencies before code) takes advantage of Docker's build caching.
  • docker-compose.yml, written in YAML, describes an entire multi-container setup — services, ports, volumes, environment variables, and dependencies between them — replacing many long individual docker run commands with one docker-compose up.
  • A handful of commands (docker ps, docker logs, docker exec, docker build, docker-compose up/down) cover the overwhelming majority of day-to-day Docker use.

👦 Nephew: So Docker was never really about "containers are cool" — it's about making an application's entire environment reproducible, on purpose, every single time.

👨‍🦳 Uncle: Exactly that. Everything else — images, volumes, networks, Compose files — are just the specific tools built to make that one promise actually true in practice.

Top comments (0)