Docker basics: from 'it works on my machine' to actually understanding what you're doing
Posted on April 16, 2026 • 16 minutes • 3207 words
Table of contents
- What Docker actually is (beyond the hype)
- Your first container:
hello-world - Images, tags, and registries: where all this stuff comes from
- Working with images: list, download, delete
docker run: a “live” container- “Popping the hood”: getting inside a container with
exec - Volumes: keeping your data alive when the container dies
- Pushing and pulling your images: push/pull and registries
- How to know if you can “handle” Docker
- Quick glossary
- Sources and references
Docker is one of those tools that everyone “uses” and almost nobody bothers to understand. Which is a shame, because once you get it, it’s a game-changer as a developer: you go from “it works on my machine” to “it works on my machine, on yours, in CI, and in production — and I can prove it.”
The goal of this tutorial is simple: if you’ve never touched Docker, by the end you should be able to navigate the core concepts comfortably, run containers, work with volumes, and understand what you’re doing. Not blindly copy-pasting commands like a trained monkey, but knowing why they work.
What Docker actually is (beyond the hype)
Technically, Docker relies on OS-level virtualization mechanisms: namespaces, cgroups, and friends. That means you’re not spinning up a full virtual machine with its own kernel (like you would with VirtualBox, VMware, or similar) — you’re running isolated processes that share the host’s kernel but live in their own little world: their own filesystem, their own network interfaces, their own process tree.
Before touching anything, there are two concepts you need to have nailed down or everything else is going to sound like white noise.
An image is an immutable template: a frozen filesystem along with its metadata (default command, environment variables, exposed ports…).
A container is a running instance of that image: a process (or group of processes) that boots up from that template. The image is the recipe; the container is the dish on the table. If the dish turns out bad, you toss the container and serve another one from the same recipe without anyone noticing.
If you come from an object-oriented programming background, the analogy is straightforward:
- Image ~ class.
- Container ~ object.
And if your mental reference is traditional servers:
- Image ~ a very well-defined server snapshot.
- Container ~ a “running” server based on that snapshot, but much lighter and disposable.
Your first container: hello-world
With Docker installed (Docker Desktop on Mac/Windows, Docker Engine on Linux; if you need a step-by-step guide, check the Docker installation article ), let’s get the initiation ritual out of the way:
docker run hello-world
On the surface it looks like magic: you type that, some friendly logs scroll by, and it’s over.
Under the hood, it’s more interesting than it looks. Docker first checks whether it already has the hello-world image on your local machine. Since it doesn’t (it’s your first time — nobody’s born knowing this stuff), it requests it from the default registry (Docker Hub
), downloads it, stores it locally, and creates a container from it.
Inside that container, the command defined by the image runs — in this case, printing some welcome messages and exiting. When the process finishes, the container moves to the “Exited” state and just sits there, taking up a tiny bit of disk but doing nothing else, like a decorative vase with dead flowers.
You never typed stop at any point because there’s nothing to “stop” manually: the process was born, did its job (print the text), and died. A container isn’t an “immortal being” — if the main process ends, so does the container.
You can verify this:
docker ps -a
You’ll see something like:
CONTAINER ID IMAGE COMMAND STATUS
abcd1234 hello-world "/hello" Exited (0) ...
You’ve just done your first pull + run without even realizing it.
Images, tags, and registries: where all this stuff comes from
An image isn’t just a loose name; it has structure:
<registry>/<namespace>/<image_name>:<tag>
Examples:
ubuntu:22.04–> implicit registry (Docker Hub), implicit namespace (library), tag22.04.nginx:alpine.mycompany/api:1.3.0.ghcr.io/my-org/my-service:02a3f7d(GitHub Container Registry with a commit-based tag).
If you don’t specify a :tag, Docker defaults to :latest, which is convenient in development but a trap in serious environments: “latest” means “whatever someone last pushed,” not “what you think it is.”
What is a registry
A registry is a service that stores Docker images and lets you:
docker pull name:tag–> download an image.docker push name:tag–> upload an image.
There are public/remote ones — Docker Hub (docker.io), GitHub Container Registry (ghcr.io), GitLab Container Registry, and each cloud provider’s own: AWS ECR, Azure ACR, GCP Artifact Registry… — and there are private ones, which you set up on your own infrastructure using tools like Docker’s registry:2, Artifactory, or Harbor. The difference is who pays for the hosting and who controls access, but conceptually they all do exactly the same thing: store images and let you push and pull them.
To talk to an authenticated registry you need to login:
docker login
# it will ask for username + password or token
If you work with ECR, ACR, GCR, or other cloud registries, the login is usually done through the cloud CLI, which generates a temporary token and passes it to Docker under the hood. Check your registry’s documentation — they usually include instructions for logging in and pushing/pulling images.
Working with images: list, download, delete
Basic survival commands:
# List images you have locally
docker images
# Explicitly download an image
docker pull nginx:alpine
# Delete an image (if no container is using it)
docker rmi nginx:alpine
Nothing stops you from having multiple versions of the same thing:
docker pull postgres:15-alpine
docker pull postgres:14-alpine
At the end of the day, your local images are your cache: anything Docker finds there, it won’t need to download from the registry again.
docker run: a “live” container
Let’s run something a little less lame than hello-world. For example, Nginx:
docker run --name my-nginx -p 8080:80 -d nginx:alpine
In plain English:
--name my-nginx: I want to refer to this container by a decent name.-p 8080:80: map port 80 inside the container to port 8080 on your machine.-d: “detached” — start it in the background.nginx:alpine: base image.
Open http://localhost:8080 and you’ll see Nginx’s welcome page.
Check what’s running:
docker ps
When you’re done playing with that pretty but frankly useless container:
docker stop my-nginx
docker rm my-nginx
Key concept: containers are disposable resources. Treat them like paper cups, not like your grandma’s fine china.
Stopping a container doesn’t delete the image. Removing it (rm) doesn’t delete the volume (if there is one). Deleting the image doesn’t touch the remote registry. They’re independent layers: you can blow one away without the others even noticing.
“Popping the hood”: getting inside a container with exec
The urge to “see what’s inside” is natural and even healthy. Taking technology apart is the first step to understanding it.
If you have a running container, you can open a shell into it:
docker exec -it my-nginx sh
or, if it has bash:
docker exec -it my-container bash
-it is the combination of -i (interactive mode, keeping STDIN open so you can type) and -t (pseudo-TTY, so the shell behaves like a real terminal and not like a faucet dripping characters with no rhyme or reason). Without those two flags, your interactive session would have the usability of a waterlogged keyboard.
Once inside, what you see is the container’s filesystem:
you can list /, check processes (ps aux), inspect config paths, or run any command.
To exit the container’s shell:
exit
You’re not “logging into a remote machine” — you’re still on your host, just opening an interactive session into an isolated process.
Even though you can run any command inside the container and even modify things, the changes will only live in that specific container, not in the original image. It’s like drawing mustaches on a photocopy: go wild, but the original is still sitting untouched in the copier.
Volumes: keeping your data alive when the container dies
If you start a container, write data inside it, and then delete it, that data is gone. That’s a feature when you want ephemeral environments, but not great for things like databases or even web servers.
That’s where volumes and bind mounts come in.
Bind mounts: your folder –> the container’s folder
Here’s a very common scenario: you have a folder with static HTML and you want to serve it with Nginx:
mkdir -p ~/web-demo
echo "Hello Docker" > ~/web-demo/index.html
docker run --name nginx-web \
-p 8080:80 \
-v ~/web-demo:/usr/share/nginx/html:ro \
-d nginx:alpine
The -v host_path:container_path:ro flag connects a folder on your machine (host_path) with a path inside the container (container_path). The :ro mounts it read-only, which is a good idea when the container doesn’t need to write there — better safe than having to explain to your Monday morning self why the dev folder woke up empty.
If you change index.html on your host, the change shows up when you refresh the browser.
This is super useful in development: you don’t have to rebuild the image every time you touch a file.
Docker-managed volumes
When you don’t care about the physical path on the host but you do care about data persistence, you use Docker volumes:
docker volume create mysql-data
docker run --name mysql-demo \
-e MYSQL_ROOT_PASSWORD=secret \
-v mysql-data:/var/lib/mysql \
-d mysql:8.0
In this example, mysql-data is a volume that Docker will create and manage for you — you don’t need to know where it physically lives on disk, just that it exists. And /var/lib/mysql is the path where MySQL stores its data inside the container.
If you delete the container tomorrow, the volume is still there with the data intact, patiently waiting for you to spin up another one that needs it.
You can inspect and list them:
docker volume ls
docker volume inspect mysql-data
docker volume rm mysql-data # careful: you're deleting the data
When should you use each one?
For your app’s code during development, bind mounts: edit in your IDE, refresh the browser, and see the changes instantly without rebuilding anything.
For service data like databases or message queues, Docker volumes: you need the data to outlive the container, but you couldn’t care less which corner of the disk it lives in.
From your code to an image: the bare minimum Dockerfile
Up until now we’ve been using other people’s images. But the whole point of Docker is packaging your application.
A minimal example for a Node.js API:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
To build and run:
# Build
docker build -t my-user/my-api:dev .
# See the image
docker images | grep my-user
# Run
docker run --name my-api -p 3000:3000 my-user/my-api:dev
It’s worth understanding what each line does, because they’re not magic spells.
FROM picks the base image, your starting point with OS and runtime included.
WORKDIR sets the working directory inside the image, so subsequent commands know where they’re standing.
COPY and RUN stack layers on top of that base: copying files, installing dependencies, setting the stage.
And CMD defines the main process that will start every time someone launches a container from this image. Think of it like a recipe where each instruction adds an ingredient, and CMD is the moment you put the dish in the oven.
Layers: why Docker doesn’t rebuild everything every time
Each Dockerfile instruction that modifies the filesystem (FROM, COPY, RUN…) generates a layer. An image isn’t a monolithic block: it’s a stack of layers piled on top of each other, like sheets of puff pastry. The first layer is the base image (FROM node:20-alpine), the next one is the result of copying your package*.json, the next is what’s left after running npm ci, and so on.
The interesting part is that each layer is immutable and has a unique hash. When Docker rebuilds your image, it compares each instruction against what it already has cached: if the instruction hasn’t changed and the files it uses haven’t either, Docker reuses the previous layer and skips the work. It only rebuilds from the first changed layer downward.
That’s why in the example Dockerfile we copy package*.json and run npm ci before copying the rest of the code. If you’ve only touched a .js file, Docker reuses the dependency layers (which tend to take a while) and only rebuilds the final COPY . . layer. If you did it the other way around — copy everything then install — any code change would also invalidate the npm ci layer, and you’d be waiting for all dependencies to reinstall every single time. The difference between a 3-second build and a 2-minute one usually comes down to understanding how layers work.
When you docker push, Docker only uploads the layers the registry doesn’t already have. And when you docker pull, it only downloads the ones you’re missing. That reuse is what makes moving images around much faster than the total size would suggest.
No need to get into multi-stage builds, layer optimization, or security just yet. The important thing is that you now know how to build an image from your repo and make it run.
Pushing and pulling your images: push/pull and registries
At some point you’re going to want to use that image outside your laptop: on another machine, in a CI pipeline, in a Kubernetes cluster, etc. That’s where registries come in.
1. Naming the image properly
When you build an image with docker build -t my-api:dev ., that name only makes sense on your machine. For a remote registry to accept it, the image name needs to include the full destination path: your user or namespace on the registry, the repository name, and a version tag. It’s like putting the return address and destination on a package before taking it to the post office — without that, the mail carrier has no idea what to do with it.
Let’s say you have my-api:dev locally and you want to push it to Docker Hub:
docker tag my-api:dev myuser/my-api:1.0.0
This doesn’t copy or duplicate the image; it simply gives it a new alias that the registry will understand. Now myuser/my-api:1.0.0 points to the exact same bytes as my-api:dev, but with a name that Docker Hub recognizes as “this goes to the myuser account, my-api repo, version 1.0.0.”
2. Login and push
docker login # if it's Docker Hub
docker push myuser/my-api:1.0.0
Docker will talk to the registry, authenticate, and upload only the layers that aren’t already there.
On another machine:
docker pull myuser/my-api:1.0.0
docker run --rm -p 3000:3000 myuser/my-api:1.0.0
For other registries the ritual is identical — only who asks for your credentials changes.
With AWS ECR, you first grab a token using aws ecr get-login-password | docker login ... and then push/pull with URLs like 123456789012.dkr.ecr.eu-west-1.amazonaws.com/my-api:1.0.0.
With Azure ACR, you run az acr login --name myregistry and work with myregistry.azurecr.io/my-api:1.0.0.
With GitHub Container Registry, log in with docker login ghcr.io and the URL will be ghcr.io/my-org/my-api:1.0.0.
Learn one, you’ve learned them all: the only real difference is who gets the bill at the end of the month.
Multiple services at once: a taste of docker compose
Running an API and a database with multiple docker run commands back-to-back is doable. Keeping that up in a team of more than one person (you) without someone ending up crying in Slack is a different story. That’s what docker compose is for.
A very simple docker-compose.yml:
version: "3.9"
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: example
POSTGRES_USER: app
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
api:
build: .
depends_on:
- db
environment:
DATABASE_URL: postgres://app:example@db:5432/appdb
ports:
- "3000:3000"
volumes:
db-data:
And the four commands that separate chaos from order:
docker compose up # starts everything in the foreground
docker compose up -d # starts everything in the background
docker compose logs -f # follows logs from all services
docker compose down # stops and removes containers (volumes only if you ask)
The beauty of this is that your development environment is now declarative. Someone clones the repo, runs docker compose up, and before they’ve even finished making coffee they’re working with the same topology as the rest of the team.
No more “I’m missing this environment variable” or “what version of Postgres are you running?” Everything is in the file, and the file doesn’t lie (at least not as much as the wiki docs).
How to know if you can “handle” Docker
If after reading this you can explain out loud the difference between an image and a container, fire up a docker run with ports and volumes without being glued to your notes, list, stop, and delete containers (and pop a shell into them with exec when curiosity strikes), write a Dockerfile for your app, build the image, move it between registries with push/pull, and spin up an API with its database using docker compose up… you’re no longer “someone who copies commands from Stack Overflow.” You’re a developer who understands Docker well enough to use it with good judgment, and in this industry that already puts you further ahead than you’d think.
From here, the next level is diving into networking, security, multi-stage builds, minimal images, production best practices, and orchestrators like Kubernetes. But that’s another tutorial.
Quick glossary
In case any of these terms sounded like someone was reading you the phone book in Klingon, here’s the decoder ring.
- image: an immutable template containing a frozen filesystem plus metadata (default command, environment variables, ports). Containers are created from it.
- container: a running instance of an image. An isolated process (or group of processes) that starts from that template and, when it finishes, can be discarded without a trace.
- registry: a service that stores and distributes Docker images. Docker Hub is the default public registry; there are also private ones (ECR, ACR, Harbor…).
- tag: a label that identifies a specific version of an image (e.g.,
python:3.12ornginx:alpine). If you don’t specify one, Docker defaults tolatest. - bind mount: a direct mount of a host folder inside the container. Changes are reflected on both sides in real time.
- volume: storage space managed by Docker, independent of the container’s lifecycle. Ideal for persisting database or queue data.
- namespace / cgroup: Linux kernel mechanisms that isolate processes (namespaces) and limit their resources — CPU, memory, network — (cgroups). They’re the foundation Docker builds containers on.
- Dockerfile: a text file that describes, instruction by instruction, how to build a Docker image. Each instruction (
FROM,COPY,RUN,CMD…) adds a layer to the final result. - docker compose: a tool that lets you define and launch multiple containers at once from a YAML file (
docker-compose.yml). Ideal for development environments where you need several coordinated services (API + database, for example). - layer: each Dockerfile instruction that modifies the filesystem generates an immutable layer. Images are stacks of reusable layers, which is what makes builds and transfers (push/pull) incremental.
- host: the physical or virtual machine where Docker runs. When you see “host port” or “host folder,” it refers to your actual computer, not the container’s isolated environment.
Sources and references
The links you would’ve Googled anyway, bundled up so you don’t have to fight for them.
- Docker installation article - iamlino.net. Step-by-step guide to installing Docker painlessly.
- Docker Desktop - Docker Inc. Installing and using Docker Desktop on Mac and Windows.
- Docker Engine - Install - Docker Inc. Installation guide for Docker Engine on Linux.
- Docker Hub - Docker Inc. Public Docker image registry.
- Dockerfile reference - Docker Inc. Complete reference for Dockerfile instructions.
- Docker Compose - Docker Inc. Official Docker Compose documentation.
