Skip to content
Tech

Docker Basics for Beginners: Dockerfile, Compose and a Working Example

Learn Docker hands-on: images vs containers, a Dockerfile for a Python API, build, run, ps, logs and stop, then Docker Compose with PostgreSQL. All commands tested.

Stacks of plain teal, orange and grey shipping containers at a port at blue hour, with gantry cranes silhouetted against the sky above calm water
Stacks of plain teal, orange and grey shipping containers at a port at blue hour, with gantry cranes silhouetted against the sky above calm water
In this article

    "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-world

    We 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

    ConceptWhat it isAnalogy
    ImageA read-only template: base OS files, runtime, your code and its dependencies, plus the command to startA class, or a recipe
    ContainerA running (or stopped) instance of an image, with its own process, file system layer and networkAn object, or a dish cooked from the recipe
    DockerfileA text file of steps that builds an imageThe written recipe
    RegistryA place to store and share images, such as Docker HubA recipe library
    VolumeStorage managed by Docker that survives when containers are removedA 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.6

    Pinning 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-slim starts from the official Python image (the slim variant is smaller).
    • WORKDIR /app sets the folder inside the image where later commands run.
    • Copying requirements.txt and running pip install before copying the code means Docker can reuse the cached dependency layer when only your code changes. Rebuilds become much faster.
    • EXPOSE 8000 documents the port. It does not publish it; -p does that.
    • CMD is the command the container runs. Binding to 0.0.0.0 is 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 ps
    CONTAINER 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: 7

    Container log timestamps are in UTC by default. Stop and remove the container when you are done:

    docker stop notes
    docker rm notes

    Removing 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_URL uses the hostname db, which is the other service's name. Compose's network resolves it.
    • db uses the official postgres:18 image (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.1 on 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 ps
    NAME              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

    CommandWhat it does
    docker build -t NAME .Build an image from the Dockerfile in the current folder
    docker imagesList local images
    docker run -d --name C -p 8000:8000 IMAGEStart a container in the background and publish a port
    docker ps / docker ps -aList running / all containers
    docker logs -f CShow (and follow) a container's output
    docker exec -it C shOpen a shell inside a running container
    docker stop C / docker start CStop / restart a container
    docker rm CRemove a stopped container
    docker rmi IMAGERemove an image
    docker pull IMAGE:TAGDownload an image from a registry
    docker compose up -d --buildBuild and start all services
    docker compose psShow the stack's containers
    docker compose logs -f webFollow one service's logs
    docker compose exec db psql -U notesRun a command in a service
    docker compose down / down -vStop and remove the stack / also delete volumes
    docker system dfSee 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 build or docker 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.

    Sources

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.