igor-millis~/research/docker-from-scratch
EN/PT
← all articles

Docker from scratch: what it is, why it exists and how to use it

For people who have never used Docker: why “works on my machine” happens, how a container fixes it and how it differs from a virtual machine. Then hands-on — a first container, your own Dockerfile, layers, volumes and an application with Docker Compose.

§1The most famous sentence in programming

Everyone who has worked with software has heard (or said) this:

"But it works on my machine!"

The scene is always similar. You spend weeks building a system. It runs perfectly on your laptop. Then you send it to a colleague, or deploy it to the server, and… it breaks. A strange error, a missing library, the wrong version of something.

Why? Because your program is not just your code. It depends on a lot of invisible things around it:

  • the language version (Python 3.8? 3.12?);
  • the installed libraries (and their versions);
  • the operating system (Windows, macOS, Ubuntu, Debian…);
  • environment variables, configuration files, folders that "happened" to exist on your computer;
  • helper programs (a database, Redis, ffmpeg, libssl…).

Your code is like a plant: it only grows well in the right soil, with the right light and the right water. When you move the plant to another pot, it may die — not because the plant is wrong, but because the environment changed.

Docker solves exactly that: it lets you carry the plant together with the pot, the soil and the climate.

The difference between shipping only the code and shipping the code with its environment
The difference between shipping only the code and shipping the code with its environment

§2A story about ships (and why it is called a "container")

Before talking about computers, a quick trip back to the 1950s.

Back then, loading a ship was chaos. Every kind of cargo had its own shape: sacks of coffee, barrels of oil, wooden crates, pianos, cars. Dockworkers spent days fitting everything into the hold like a giant game of Tetris. Goods broke, were stolen, and every port had its own way of working.

Then came a ridiculously simple idea: the standardized shipping container. A metal box with a fixed size and standard fittings at the corners.

The trick is not the box itself — it is the standard:

  • The ship does not need to know whether there is coffee or a piano inside. It only knows how to carry containers.
  • Neither does the crane. Neither does the truck. Neither does the train.
  • Whoever fills the container cares about the contents. Whoever transports it only cares about the box.

Shipping costs collapsed and world trade changed forever.

Docker does the same thing for software:

Shipping world Docker world
Cargo (coffee, pianos…) Your application + everything it needs
Standardized container Docker container
Ship, train, truck Your laptop, the server, the cloud (AWS, Azure, GCP…)
"The ship does not need to know what is inside" "The server does not need Python, Node or Java installed — only Docker"

That is why Docker's logo is a whale carrying containers.

§3So what is Docker, after all?

Short definition:

Docker is a tool that packages an application together with everything it needs to run (system, libraries, configuration) into an isolated, portable unit called a container.

In other words, instead of handing over "here is my code, good luck installing the rest", you hand over "here is a box that already works; just switch it on".

Three promises sum up Docker:

  1. It works the same everywhere. If it ran on your laptop, it runs on your colleague's, on the server and in the cloud.
  2. Isolated. Each container has its own "bubble". You can have one app using Python 3.8 and another using Python 3.12 side by side, neither aware of the other.
  3. Disposable and reproducible. Broke it? Delete it and create it again in seconds, identical to the original.

§4"Isn't that just a virtual machine?"

Great question — and a very common confusion. The answer is: similar in purpose, very different in how it works.

The houses-and-apartments analogy

  • A virtual machine (VM) is like building several houses on a plot of land. Each house has its own foundation, plumbing, wiring and water tank. Fully independent — but expensive and slow to build.
  • A container is like an apartment building. Each apartment has its own locked door, its privacy, its furniture. But they all share the building's foundation, structure and water supply. Much cheaper and faster to move into.

Technically, the "shared foundation" is the kernel of the operating system — the core that talks to the hardware (CPU, memory, disk).

  • A VM simulates an entire computer and runs a complete operating system inside it (with its own kernel). It weighs gigabytes and takes minutes to boot.
  • A container reuses the host machine's kernel and isolates only what matters: files, processes, network. It weighs megabytes and starts in seconds (sometimes milliseconds).
Virtual machine versus container
Virtual machine versus container
Virtual machine Container
What it carries Full OS + app Only the app and its dependencies
Typical size Gigabytes Megabytes
Start-up time Minutes Seconds
How many fit on a server Dozens Hundreds
Isolation Very strong (virtual hardware) Strong, but shares the kernel

Fun fact: on Windows and macOS, Docker Desktop runs a very light Linux VM under the hood (on Windows, usually through WSL 2). That is because the vast majority of containers are built for the Linux kernel. You do not need to worry about it day to day — but it is good to know it is there.

Containers do not replace VMs for everything. If you need to run Windows inside Linux, or want maximum isolation between customers who do not trust each other, a VM is still the right tool. In practice, it is very common to run containers inside VMs in the cloud.

§5The 4 concepts you need to master

If you understand these four words, you understand 80% of Docker:

  1. Dockerfile
  2. Image
  3. Container
  4. Registry (e.g. Docker Hub)

The kitchen analogy

Concept Analogy What it really is
Dockerfile The cake recipe A text file with the step-by-step instructions to build the environment
Image A ready-made frozen cake, packaged The "packaged" result of the recipe. Read-only, it does not change
Container A slice served on a plate A running instance of the image. You can have several at the same time
Registry The supermarket where frozen cakes are stocked An online "repository" of images (Docker Hub, GitHub Container Registry…)

If you program, another analogy helps a lot:

An image is to a container what a class is to an object. The image is the mold; the container is the living thing created from it. From the same image you can create as many containers as you like.

And the full flow looks like this:

Flow: a Dockerfile becomes an image with build, an image becomes a container with run, and images travel through the registry with push and pull
Flow: a Dockerfile becomes an image with build, an image becomes a container with run, and images travel through the registry with push and pull
  • docker build → turns the recipe (Dockerfile) into an image.
  • docker run → starts a container from an image.
  • docker push → sends your image to the registry.
  • docker pull → downloads an image from the registry.

Keep this picture in mind. Everything else is detail on top of it.

§6Hands-on: your first container

Installing

To check that it worked, open a terminal and run:

docker --version

"Hello, world"

docker run hello-world

It looks like little, but a lot happened here:

  1. Docker looked for the hello-world image on your computer.
  2. It did not find it, so it downloaded it from Docker Hub (an automatic pull).
  3. It created a container from it.
  4. The container ran, printed the welcome message and exited.

That last point matters: a container lives as long as its main process is running. hello-world just prints some text and finishes — so the container finishes with it.

A web server in one command

Now something more fun. Let's start a real web server (Nginx) without installing anything:

docker run -d -p 8080:80 --name my-site nginx

Open your browser at http://localhost:8080. You will see the "Welcome to nginx!" page.

Breaking the command down:

Piece Meaning
docker run Create and start a container
-d detached: run in the background and give me my terminal back
-p 8080:80 Connect port 8080 on my computer to port 80 in the container
--name my-site Give the container a friendly name
nginx The image to use

Now play with it:

docker ps                  # list running containers
docker logs my-site        # show what the container printed
docker stop my-site        # stop it
docker start my-site       # start it again
docker rm -f my-site       # stop it (if needed) and delete it

Understanding -p 8080:80 (ports)

Remember the building analogy? Each container is an apartment with its own internal numbering. Nginx, inside the apartment, answers on port 80. But whoever is outside (your browser) cannot see into the apartment.

-p 8080:80 is like putting a buzzer at the front desk: "whoever rings 8080 down here gets forwarded to 80 in the my-site apartment".

Port mapping: port 8080 on the computer forwards to port 80 in the container
Port mapping: port 8080 on the computer forwards to port 80 in the container

This lets you, for example, run three Nginx servers at once, each answering on a different port of your computer:

docker run -d -p 8081:80 nginx
docker run -d -p 8082:80 nginx
docker run -d -p 8083:80 nginx

All three use port 80 on the inside — no conflict, because each one is in its own apartment.

Getting inside the container

Want to peek inside? You can open a terminal inside a running container:

docker run -d --name spy nginx
docker exec -it spy bash

Now you are "inside". Run ls /, cat /etc/os-release… You will notice there is a mini Linux in there (Debian, in Nginx's case), even if you are on Windows. Type exit to leave.

Using programs without installing them

This is one of Docker's most underrated uses: trying things without cluttering your computer.

Want a Python 3.12 to test something quickly, but don't want to install it?

docker run -it --rm python:3.12 python

--rm deletes the container automatically when you exit. Zero mess.

Need a PostgreSQL database for a university project?

docker run -d --name db -e POSTGRES_PASSWORD=password123 -p 5432:5432 postgres:16

Done, a full Postgres is running. When you no longer need it: docker rm -f db. No uninstallers, no forgotten services starting at boot, no version conflicts.

About tags (:3.12, :16): what comes after the colon is the image's version. python:3.12 and python:3.8 are different images. If you don't specify one, Docker uses :latest — handy for playing around, but dangerous in real projects, because "latest" changes over time. In serious projects, always pin the version.

§7Building your own image: the Dockerfile

So far we have only used ready-made images. The real power shows up when you package your own application.

Let's create a minimal API in Python. Create a folder my-app with three files:

app.py

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello! I am running inside a container"

requirements.txt

flask==3.0.3

Dockerfile (no extension, capital "D")

# 1. Start from an image that already has Python installed
FROM python:3.12-slim

# 2. Set the working folder inside the container
WORKDIR /app

# 3. Copy the dependency list and install it
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 4. Copy the rest of the code
COPY . .

# 5. Document the port the app uses
EXPOSE 5000

# 6. The command that runs when the container starts
CMD ["flask", "--app", "app", "run", "--host=0.0.0.0", "--port=5000"]

Read in plain English, the Dockerfile says:

"Take a slim Linux that already comes with Python 3.12. Create the /app folder. Copy the list of libraries and install them. Copy my code. When someone starts the container, run Flask."

Now build the image and run it:

docker build -t my-app .
docker run -d -p 5000:5000 --name api my-app

Go to http://localhost:5000. Your application is running in a container — and you don't even need Python installed on your computer for it to work. Anyone with Docker can run exactly the same environment.

Why --host=0.0.0.0? By default, Flask only accepts connections coming "from the machine itself" (127.0.0.1). But "the machine itself", for Flask, is the container! Your browser is outside. 0.0.0.0 means "accept connections from anywhere". This is probably the most common beginner mistake — the app starts, but the browser cannot connect.

The most used instructions

Instruction What it is for
FROM The base image you start from (always the first line)
WORKDIR The "current" folder inside the container
COPY Copies files from your computer into the image
RUN Runs a command during the build (e.g. installing packages)
ENV Sets environment variables
EXPOSE Documents which port the app uses (it does not publish the port by itself!)
CMD Default command run when the container starts

The classic confusion: RUN vs CMD. - RUN happens once, while building the image (preparing the recipe). - CMD happens every time a container starts (serving the dish).

The .dockerignore file

Like .gitignore, it lists what not to copy into the image. Create it in the same folder:

__pycache__/
.venv/
.git/
*.log
.env

This makes the image smaller, the build faster, and avoids leaking sensitive things (like an .env with passwords).

§8Layers: the secret behind the speed

Here is an intuition that separates people who use Docker from people who understand Docker.

An image is not a single block. It is a stack of layers, like a lasagna — and each Dockerfile instruction creates a layer.

An image as a stack of layers: each Dockerfile instruction produces a layer; the container adds a thin writable layer on top
An image as a stack of layers: each Dockerfile instruction produces a layer; the container adds a thin writable layer on top

Two very practical consequences:

1. Cache. When you run docker build again, Docker reuses every layer that has not changed. It only rebuilds from the first layer that changed — and everything after it.

That is why our Dockerfile copies requirements.txt before the rest of the code:

COPY requirements.txt .        # rarely changes
RUN pip install -r requirements.txt   # slow, but cached
COPY . .                       # changes all the time

If you edit app.py, only the last layer is rebuilt. pip install (which can take minutes) comes from the cache. If you put COPY . . before pip install, any change to the code would force reinstalling every library.

Golden rule: put what rarely changes at the top of the Dockerfile, and what changes often at the bottom.

2. Sharing. If you have ten different images that all start with FROM python:3.12-slim, that base layer is stored only once on disk. The ten images point to it. Huge savings in space and download time.

And the container? When you start a container, Docker puts a thin writable layer on top of the image (which is read-only). Everything the container changes goes into that layer. That is why you can have 50 containers from the same image using almost no extra disk.

…which leads us to an important problem.

§9Containers are disposable — what about your data?

That writable layer dies with the container. If you delete the Postgres container, all the data in the database goes with it.

This is not a bug, it is a philosophy: containers should be treated like cattle, not pets. You don't lovingly tend to one specific container; if it gets sick, you replace it with an identical one. But the data has to survive.

The solution is volumes.

The hotel room analogy

A container is like a hotel room. When you check out, housekeeping clears everything. If you left something in the room, it's gone.

A volume is like the hotel's safe / luggage room: it lives outside the room. You can change rooms as many times as you want — your things stay put.

Volumes: the data lives outside the container and survives when it is deleted
Volumes: the data lives outside the container and survives when it is deleted

In practice:

# create a Postgres container storing its data in the "db-data" volume
docker run -d --name db \
  -e POSTGRES_PASSWORD=password123 \
  -v db-data:/var/lib/postgresql/data \
  postgres:16

-v db-data:/var/lib/postgresql/data means: "the folder where Postgres stores its data, inside the container, actually lives in the db-data volume".

Now you can docker rm -f db, create another container with the same -v, and the data will still be there.

There are two main kinds:

Kind Example When to use
Named volume -v db-data:/path Data for databases and services. Docker manages where it lives.
Bind mount -v ./my-code:/app Connect a folder on your computer to the container. Great for development: you edit code in your editor and the container sees the change immediately.

§10Several containers together: Docker Compose

Real applications are rarely a single container. Usually you have something like:

  • an API (your code);
  • a database (Postgres);
  • a cache (Redis);
  • maybe a frontend.

Starting all of that with four giant docker run commands, remembering ports, volumes, passwords and networks, is tedious and error-prone.

Docker Compose fixes that: you describe the whole system in a compose.yaml file and start everything with one command.

If a Dockerfile is the recipe for one dish, Compose is the menu for the whole dinner: starter, main course, dessert, and the order to serve them.

Let's extend our API to count visits using Redis.

app.py

from flask import Flask
import redis

app = Flask(__name__)
cache = redis.Redis(host="redis", port=6379)

@app.route("/")
def hello():
    visits = cache.incr("visits")
    return f"Hello! This page has been viewed {visits} times."

requirements.txt

flask==3.0.3
redis==5.0.8

compose.yaml

services:
  api:
    build: .
    ports:
      - "5000:5000"
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  redis-data:

And now, the magic:

docker compose up -d

Go to http://localhost:5000 and reload the page a few times. The counter goes up.

Docker Compose: one file describes the API and Redis, which talk over an internal network
Docker Compose: one file describes the API and Redis, which talk over an internal network

Notice a lovely detail: in the Python code, we connect to Redis using host="redis". "redis" is the service name in compose.yaml. Compose automatically creates an internal network where each service finds the others by name, like extensions on an office phone system. Notice too that Redis has no ports: — it does not need to be reachable from outside, only by the API. Safer.

Everyday commands:

docker compose up -d        # start everything in the background
docker compose ps           # service status
docker compose logs -f api  # follow the API logs
docker compose down         # stop and remove the containers (volumes stay)
docker compose up -d --build  # rebuild the images and start again

For a new person on the team, onboarding becomes: "clone the repository and run docker compose up". No 30-step installation document.

§11Cheat sheet: essential commands

Images

docker images                 # list local images
docker pull nginx:1.27        # download an image
docker build -t name:tag .    # build an image from the Dockerfile in the current folder
docker rmi name:tag           # delete an image

Containers

docker run [options] image    # create and start a container
docker ps                     # running containers
docker ps -a                  # all of them, including stopped ones
docker stop name              # stop
docker start name             # start a stopped container
docker rm name                # delete (must be stopped; use -f to force)
docker logs -f name           # follow the logs
docker exec -it name sh       # open a terminal inside the container

Most used docker run options

Option Meaning
-d Run in the background
-it Interactive mode with a terminal
--rm Delete the container when it exits
-p HOST:CONTAINER Publish a port
-v SOURCE:TARGET Mount a volume or folder
-e KEY=value Set an environment variable
--name name Give the container a name

Cleaning up

docker system df              # how much space Docker is using
docker system prune           # remove stopped containers, unused networks and dangling images

docker system prune -a --volumes is the "nuclear option": it also deletes unused images and volumes — meaning it can delete database data. Use it knowing what you are doing.

§12Common beginner mistakes

"I ran the container and it died immediately." Remember: a container lives as long as its main process lives. Run docker logs container-name to see the error. Often it is a wrong command in CMD or a missing environment variable.

"The app is running, but the browser can't connect." Two top causes: (1) you forgot -p to publish the port; (2) the app listens on 127.0.0.1 instead of 0.0.0.0 inside the container.

"I deleted the container and lost my data." You needed a volume. Anything that must survive goes into a volume.

"I changed the code and nothing changed." An image is a snapshot taken at build time. Changed the code? You need docker build again (or docker compose up --build). In development, a bind mount avoids this.

"Inside the container, localhost can't find my database." Inside a container, localhost is the container itself, not your computer. Use the service name (with Compose) or host.docker.internal to reach the host machine (on Docker Desktop).

"My disk is full." Old images and containers pile up. docker system df shows the size; docker system prune cleans up.

§13Good practices (the "next level")

  • Pin versions. FROM python:3.12-slim instead of FROM python. Reproducibility is the reason to use Docker, after all.
  • Prefer slim images. -slim and -alpine variants are much smaller. Less installed software = lighter image and a smaller attack surface.
  • Order the Dockerfile with the cache in mind. What rarely changes at the top, what changes often at the bottom.
  • Use .dockerignore.
  • Never put passwords inside the image. No ENV PASSWORD=... in the Dockerfile and no copying .env into the image. Pass secrets at run time (-e, env_file in Compose, or secret-management tools).
  • One main process per container. The API in one container, the database in another. Don't cram everything into one box — combine them with Compose.
  • Don't run as root when you can avoid it. Many images let you set a regular user with the USER instruction. It is an extra layer of security.

§14What Docker is not

To finish the mental map, let's clear up a few myths:

  • It is not a programming language. It is a packaging and execution tool.
  • It is not a virtual machine (as we saw, it shares the kernel).
  • It is not just for "DevOps". Data scientists, students and developers in any field benefit from it.
  • It is not required for everything. A 20-line script only you use probably doesn't need a container. Docker shines when dependencies, several people or several environments are involved.
  • It is not the same as Kubernetes. Docker runs containers on one machine. Kubernetes orchestrates thousands of containers across many machines (decides where each one runs, restarts the ones that crash, scales with demand). If Docker is the container, Kubernetes is the management of the whole port.

§15Everything in one paragraph

Your software depends on an environment, and environments differ on every machine. Docker solves this by packaging the app together with its environment. You write a recipe (Dockerfile), build an immutable package (image), and start as many copies of that package as you want (containers), which are light because they share the host system's kernel. Images are made of reusable layers, travel through registries such as Docker Hub, keep important data in volumes, expose services through ports and, when the system has several pieces, are run together by Docker Compose.

If you understood this paragraph, you have left zero behind.

§16Next steps

  1. Practice. Take a project of yours (university, work, a side project) and write a Dockerfile for it.
  2. Explore Docker Hub. Look at the official images for the databases, languages and tools you already use.
  3. Read the official documentation, which is excellent: docs.docker.com/get-started.
  4. Learn multi-stage builds — a technique for producing much smaller images by separating the build environment from the runtime environment.
  5. When you feel comfortable, look into CI/CD (running tests in containers on every commit) and, later, Kubernetes.

§17Quick glossary

Term In one sentence
Container An isolated, running instance of an image.
Image An immutable package with app + dependencies, used to create containers.
Dockerfile A text file with the instructions to build an image.
Layer Each "floor" of an image; reused across builds and across images.
Registry A remote repository of images (e.g. Docker Hub).
Tag An image's "version" (what comes after the :).
Volume Storage that lives outside the container and survives it.
Bind mount A folder on your computer "mirrored" inside the container.
Docker Compose A tool to define and start several containers together from a YAML file.
Kernel The core of the operating system; containers share the host's.
Docker Desktop The Windows/macOS app that installs and runs Docker (with a light Linux VM underneath).

References: official Docker documentation (getting started, Dockerfile and Docker Compose reference); Docker Hub. Diagrams made by me for this article.