"It works on my machine" is the oldest excuse in software, and Docker is the most popular tool for getting rid of it. You package an app with everything it needs (the language runtime, libraries and settings) into an image, and that image runs the same way on your laptop, a teammate's PC or a cloud server. This guide explains the core ideas, then builds a real example: a small Python API in a container, followed by the same API plus a PostgreSQL database with Docker Compose. Everything below was built and run, and the outputs shown are real.
Versions and facts in this guide were checked against official documentation on 26 October 2026 (IST). Tools move fast, so confirm version numbers on the linked official pages before you install.
Install Docker
- Windows and macOS: install Docker Desktop from docs.docker.com (on Windows it uses WSL 2).
- Linux: install Docker Engine for your distribution by following the official instructions (Docker Desktop for Linux also exists).
Check that it works:
docker --version
docker compose version
docker run hello-worldWe tested this article with Docker Engine 26.1 and Docker Compose 2.26. Newer versions use the same commands. Note that Compose is now the docker compose subcommand (with a space). Older tutorials use the separate docker-compose tool.
Image vs container
| Concept | What it is | Analogy |
|---|---|---|
| Image | A read-only template: base OS files, runtime, your code and its dependencies, plus the command to start | A class, or a recipe |
| Container | A running (or stopped) instance of an image, with its own process, file system layer and network | An object, or a dish cooked from the recipe |
| Dockerfile | A text file of steps that builds an image | The written recipe |
| Registry | A place to store and share images, such as Docker Hub | A recipe library |
| Volume | Storage managed by Docker that survives when containers are removed | A pantry that outlives any single meal |
You can start many containers from one image. Containers are lightweight because they share the host's kernel instead of booting a full virtual machine.
The example app
A tiny notes API in Python using Flask: GET / returns a greeting, and GET/POST /notes read and write notes in PostgreSQL. The database is only used when a DATABASE_URL environment variable is set, so the same image works alone (part 1) and with a database (part 2). Create a folder notes-app with these files.
app.py
# app.py - a tiny notes API that stores data in PostgreSQL
import os
import psycopg
from flask import Flask, jsonify, request
app = Flask(__name__)
DATABASE_URL = os.environ.get("DATABASE_URL")
def get_conn():
return psycopg.connect(DATABASE_URL)
@app.get("/")
def home():
return jsonify(message="Hello from inside a Docker container!")
@app.get("/notes")
def list_notes():
with get_conn() as conn:
rows = conn.execute("SELECT id, text FROM notes ORDER BY id").fetchall()
return jsonify([{"id": r[0], "text": r[1]} for r in rows])
@app.post("/notes")
def add_note():
text = (request.get_json(silent=True) or {}).get("text")
if not text:
return jsonify(error="text is required"), 400
with get_conn() as conn:
row = conn.execute(
"INSERT INTO notes (text) VALUES (%s) RETURNING id", (text,)
).fetchone()
return jsonify(id=row[0], text=text), 201
if DATABASE_URL:
with get_conn() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS notes (id SERIAL PRIMARY KEY, text TEXT NOT NULL)")requirements.txt
flask==3.1.3
gunicorn==26.2.0
psycopg[binary]==3.3.6Pinning exact versions keeps builds repeatable. psycopg is the PostgreSQL driver, and gunicorn is a production web server for Python apps.
Part 1: Write a Dockerfile
# Dockerfile - build an image for the notes app
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]Line by line:
FROM python:3.14-slimstarts from the official Python image (the slim variant is smaller).WORKDIR /appsets the folder inside the image where later commands run.- Copying
requirements.txtand runningpip installbefore copying the code means Docker can reuse the cached dependency layer when only your code changes. Rebuilds become much faster. EXPOSE 8000documents the port. It does not publish it;-pdoes that.CMDis the command the container runs. Binding to0.0.0.0is essential: a server listening only on 127.0.0.1 inside a container cannot be reached from outside it.
Also add a .dockerignore so junk and secrets never get copied into the image:
__pycache__/
*.pyc
.venv/
.env
.git/Build, run, ps, logs, stop
# Build an image from the Dockerfile in this folder and tag it
docker build -t notes-app .
# Run it in the background, mapping host port 8000 to container port 8000
docker run -d --name notes -p 8000:8000 notes-app
# See running containers
docker psCONTAINER ID IMAGE STATUS PORTS NAMES
5b7a88309196 notes-app Up 2 seconds 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp notes(We trimmed some columns from docker ps to fit.) Now call the app and read its logs:
curl http://localhost:8000/
docker logs notes{"message":"Hello from inside a Docker container!"}
[2026-10-26 15:16:01 +0000] [1] [INFO] Starting gunicorn 26.2.0
[2026-10-26 15:16:01 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
[2026-10-26 15:16:01 +0000] [1] [INFO] Using worker: sync
[2026-10-26 15:16:01 +0000] [7] [INFO] Booting worker with pid: 7Container log timestamps are in UTC by default. Stop and remove the container when you are done:
docker stop notes
docker rm notesRemoving a container does not remove the image. docker images lists images, and docker rmi notes-app deletes one.
Part 2: Docker Compose with a PostgreSQL database
Real apps need more than one container. Docker Compose describes several services in one YAML file and starts them together on a private network where they can reach each other by service name. Add compose.yaml next to the Dockerfile:
# compose.yaml - the app plus a PostgreSQL database
services:
web:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://notes:notes_pw@db:5432/notes
depends_on:
db:
condition: service_healthy
db:
image: postgres:18
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes_pw
POSTGRES_DB: notes
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U notes -d notes"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:What it does:
- web is built from our Dockerfile. Its
DATABASE_URLuses the hostnamedb, which is the other service's name. Compose's network resolves it. - db uses the official
postgres:18image (PostgreSQL 18 is the current major version). The environment variables create the user, password and database on first start. - db-data is a named volume, so data survives container restarts and removals. From PostgreSQL 18, the official image expects volumes at
/var/lib/postgresql(older versions used/var/lib/postgresql/data). This detail trips up people copying older examples. - healthcheck + depends_on make the web service wait until Postgres actually accepts connections. We use
pg_isready -h 127.0.0.1on purpose: checking over TCP avoids a false "ready" while the image is still running its first-time setup.
Passwords written in compose files are fine for local learning. In real deployments, use secrets or environment files kept out of Git.
Run the stack
docker compose up -d --build
docker compose psNAME IMAGE SERVICE STATUS PORTS
notes-app-db-1 postgres:18 db Up 8 seconds (healthy) 5432/tcp
notes-app-web-1 notes-app-web web Up 3 seconds 0.0.0.0:8000->8000/tcp(Some columns are trimmed to fit.) Add and read notes:
curl -X POST http://localhost:8000/notes \
-H "Content-Type: application/json" -d '{"text": "Learn Docker"}'
curl -X POST http://localhost:8000/notes \
-H "Content-Type: application/json" -d '{"text": "Then learn Compose"}'
curl http://localhost:8000/notes{"id":1,"text":"Learn Docker"}
{"id":2,"text":"Then learn Compose"}
[{"id":1,"text":"Learn Docker"},{"id":2,"text":"Then learn Compose"}]Look inside the database directly with psql running in the db container:
docker compose exec db psql -U notes -d notes -c "SELECT * FROM notes;" id | text
----+--------------------
1 | Learn Docker
2 | Then learn Compose
(2 rows)Prove the data persists
docker compose down # removes containers and network, keeps the volume
docker compose up -d
curl http://localhost:8000/notes[{"id":1,"text":"Learn Docker"},{"id":2,"text":"Then learn Compose"}]Both notes survived because they live in the db-data volume. To wipe everything, including the data, run docker compose down -v.
New to SQL? Our SQL basics guide has queries you can run in this same psql session. For a bigger PHP stack, see a Docker Compose setup for Laravel, MySQL and Redis.
Docker command cheat-sheet
| Command | What it does |
|---|---|
docker build -t NAME . | Build an image from the Dockerfile in the current folder |
docker images | List local images |
docker run -d --name C -p 8000:8000 IMAGE | Start a container in the background and publish a port |
docker ps / docker ps -a | List running / all containers |
docker logs -f C | Show (and follow) a container's output |
docker exec -it C sh | Open a shell inside a running container |
docker stop C / docker start C | Stop / restart a container |
docker rm C | Remove a stopped container |
docker rmi IMAGE | Remove an image |
docker pull IMAGE:TAG | Download an image from a registry |
docker compose up -d --build | Build and start all services |
docker compose ps | Show the stack's containers |
docker compose logs -f web | Follow one service's logs |
docker compose exec db psql -U notes | Run a command in a service |
docker compose down / down -v | Stop and remove the stack / also delete volumes |
docker system df | See disk space used by Docker |
Common beginner problems
- "Connection refused" from the browser: the server inside is bound to 127.0.0.1 instead of 0.0.0.0, or you forgot
-p. - "Port is already allocated": something else uses that host port. Map a different one, for example
-p 8080:8000. - Code changes do not show up: images are snapshots. Rebuild with
docker buildordocker compose up --build. - Database data vanished: no volume, or the volume is mounted at the wrong path for your Postgres version.
Keep your Dockerfile and compose.yaml in version control with the rest of the code (see Git and GitHub for beginners). The Flask code here follows the same patterns as our REST APIs guide.
FAQ
Is Docker a virtual machine?
No. A VM runs a full guest operating system. A container is an isolated process that shares the host's kernel, so it starts in seconds and uses far less memory. On Windows and macOS, Docker Desktop runs a lightweight Linux VM behind the scenes.
What is the difference between CMD and RUN?
RUN executes while the image is being built, for example to install packages. CMD sets the default command when a container starts.
Do I still need a virtual environment inside the container?
Usually not. The container already isolates your app's Python packages from the host.
Docker Compose or Kubernetes?
Compose is ideal for local development and single-server setups. Kubernetes orchestrates containers across many machines and is worth learning after Docker basics.