Course 2 · Hands-on

Docker & Local Databases

A data engineer needs databases and tools running locally — and running identically to how they'll run in production. Docker makes that possible: it packages whole environments into containers you can start, stop, and reproduce with one command. In this lab you'll spin up a real Postgres database and meet DuckDB, your local analytics engine. Type every command; the comfort comes from doing it.

Setup — do this first

Install Docker. On macOS or Windows, install Docker Desktop (Windows users: enable the WSL2 backend so Docker works inside your Ubuntu terminal). On Linux, install Docker Engine. Then verify it's alive:

terminal
docker --version          # prints a version, e.g. Docker version 27.x
docker run hello-world     # pulls a tiny image and runs it

If hello-world prints a friendly "Hello from Docker!" message, you're ready. If you get "Cannot connect to the Docker daemon," make sure Docker Desktop is actually running (look for the whale icon).

Why containers

Imagine you write a pipeline on your laptop and it works perfectly. You hand it to a teammate, or push it to a server — and it breaks. A different Python version, a missing system library, a database configured slightly differently. The infamous "but it works on my machine." Containers exist to delete that sentence from your vocabulary.

A container ships your whole environment, not just your code — the operating system bits, the libraries, the exact versions, the config — all bundled together. Wherever Docker runs, your container runs the same way. You're not sending a recipe and hoping the other kitchen has the right ingredients; you're sending the whole sealed kitchen.

The shipping-container analogy

Before standardized steel shipping containers, loading a cargo ship meant hand-stacking barrels, crates, and sacks — slow and different every time. The container standardized the box: any crane, ship, or truck handles it the same way, no matter what's inside. Software containers do the same for code — a standard unit that every machine moves and runs identically. You stop worrying about what's inside and just move the box.

For a data engineer this is a superpower. Need Postgres 16? Redis? Kafka? A specific Spark version? You don't install and configure each one by hand on your laptop (and risk wrecking it). You pull a container and it just runs — the same on your machine, your teammate's, and the production server. That's the reproducibility principle from Chapter 00, made concrete.

Images vs containers

Two words you'll hear constantly, and beginners mix them up. The distinction is exactly like a class vs an instance in programming:

  • An image is the blueprint — a frozen, read-only template (e.g. "Postgres 16"). It sits in a registry (Docker Hub by default) until you pull it.
  • A container is a running instance of an image. You can start many containers from one image, just like many objects from one class.
registry (Docker Hub) │ docker pull / docker run ▼ ┌───────────────┐ docker run ┌───────────────┐ │ IMAGE │ ───────────────▶│ CONTAINER │ ← a live, running instance │ postgres:16 │ (instance of) │ (your db) │ │ read-only │ │ read+write │ │ blueprint │ ───────────────▶│ CONTAINER │ ← you can run several └───────────────┘ └───────────────┘ a class instances

The core verbs. Try them with a tiny database to feel the loop (we'll use the friendlier Compose approach for real work shortly):

terminal
docker pull postgres:16      # download the image from the registry
docker run -d --name demo \
  -e POSTGRES_PASSWORD=secret postgres:16   # start a container in the background (-d)
docker ps                    # list RUNNING containers (their id, name, status, ports)
docker logs demo             # see what the container printed
docker stop demo             # stop it
docker rm demo               # remove the stopped container
✓ You should see

docker ps lists a container named demo running postgres:16 with a status like Up 10 seconds. After docker stop demo, running docker ps again shows nothing (it only lists running containers — add -a to see stopped ones). You just created and destroyed a database server in four commands.

CommandWhat it does
docker pull imageDownload an image from a registry
docker run imageCreate & start a container from an image
docker ps / docker ps -aList running / all containers
docker logs nameShow a container's output
docker stop nameStop a running container
docker rm nameRemove a stopped container

Docker Compose

Those docker run lines get long fast — environment variables, ports, volumes, names. And a real platform isn't one container; it's several that need to talk to each other. Typing all that by hand every time is error-prone and, worse, not written down anywhere.

Docker Compose fixes this. You declare your whole stack — every service, its image, its settings — in one file called docker-compose.yml. Then a single command brings the entire stack up. The file is the documentation, and it lives in git.

docker-compose.yml ──▶ docker compose up -d ──▶ ┌──────────────┐ (declares the stack) │ postgres │ service │ ├──────────────┤ │ one file, in git, │ (later: │ service │ reproducible │ warehouse, │ ▼ │ airflow…) │ describes what you WANT, └──────────────┘ not the steps to get there the Compose stack
CommandWhat it does
docker compose up -dStart all services in the background (-d = detached)
docker compose psShow the status of this stack's services
docker compose logs -fFollow the logs of all services (Ctrl-C to stop following)
docker compose downStop & remove the stack's containers
Declarative beats manual

A pile of docker run commands is imperative — a sequence of steps you must remember and re-type. A docker-compose.yml is declarative — you describe the end state you want and Compose figures out how to get there. It's reproducible, it's reviewable, it's code. This is the same shift you'll see again with infrastructure-as-code later in the curriculum.

Run Postgres (the OLTP "source system")

Let's run a real database. Postgres is the classic open-source relational database — the kind of OLTP (Online Transaction Processing) system that powers the apps generating your data: orders, signups, rentals. In our world it stands in for the source system you extract data from.

Create a project folder and a Compose file:

terminal
mkdir -p ~/mini-griddp && cd ~/mini-griddp
docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: griddp
      POSTGRES_PASSWORD: griddp_pw
      POSTGRES_DB: source
    ports:
      - "5432:5432"          # map host port 5432 -> container port 5432
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume keeps data across restarts

volumes:
  pgdata:

Bring it up and check it:

terminal
docker compose up -d        # start Postgres in the background
docker compose ps           # confirm it's running
✓ You should see

docker compose ps lists a service (something like mini-griddp-postgres-1) with state running and ports 0.0.0.0:5432->5432/tcp. If it's not running, check docker compose logs — most first-time issues are a port 5432 already in use (another Postgres) or Docker Desktop not started.

Now open a SQL shell inside the container using docker compose exec, which runs a command in a running service. The built-in psql client is already there:

terminal
docker compose exec postgres psql -U griddp -d source

You're now at a source=# prompt — that's Postgres waiting for SQL. Type these:

psql (source=#)
CREATE TABLE customers (
    id    INT PRIMARY KEY,
    name  TEXT,
    city  TEXT
);

INSERT INTO customers (id, name, city) VALUES
    (1, 'Ada',  'Austin'),
    (2, 'Grace','Seattle'),
    (3, 'Linus','Helsinki');

SELECT * FROM customers;
✓ You should see

A neat three-row table:

output
 id | name  |   city
----+-------+----------
  1 | Ada   | Austin
  2 | Grace | Seattle
  3 | Linus | Helsinki
(3 rows)

Type \q to leave psql. You just ran a production-grade database, created a table, and queried it — all without installing Postgres on your laptop.

DuckDB (the OLAP "warehouse")

Postgres runs as a server — a separate process you connect to. DuckDB works differently: it's in-process, meaning it runs inside your program (or CLI) with no server to start, no ports, no container. Think of it as "SQLite for analytics." It's built for OLAP (Online Analytical Processing) — scanning and aggregating lots of rows fast — so we treat it as your local data warehouse.

Its party trick: it queries CSV and Parquet files directly with SQL, no loading step. Install it (in your Chapter 04 project, or the standalone CLI):

terminal
uv add duckdb            # add the Python library to your project
# OR install the standalone CLI:
#   macOS/Linux:  brew install duckdb

Make a small CSV (the same kind of file you wrangled in Chapter 02) and query it straight from SQL:

terminal
cat > sales.csv <<'EOF'
date,region,amount
2026-06-01,us,120
2026-06-01,eu,80
2026-06-02,us,200
2026-06-02,eu,95
EOF

duckdb        # start the DuckDB CLI (or run python and import duckdb)
duckdb (D)
-- read the CSV directly — no import, no schema setup
SELECT region, SUM(amount) AS total
FROM 'sales.csv'
GROUP BY region
ORDER BY total DESC;
✓ You should see
output
┌─────────┬───────┐
│ region  │ total │
├─────────┼───────┤
│ us      │   320 │
│ eu      │   175 │
└─────────┴───────┘

DuckDB read a raw CSV and ran a GROUP BY aggregation on it — that's an analytics query against a plain file, the heart of warehouse-style work. Type .quit to exit.

So when do you use which? They're complementary, not competitors:

Postgres (OLTP)DuckDB (OLAP)
Runs asA server (separate process, port)In-process (inside your program, no server)
Storage layoutRow-orientedColumnar
Built forMany small transactions (insert/update one record)Scanning & aggregating millions of rows
StrengthLive app data, concurrency, integrityFast analytics over files (CSV/Parquet)
Our role for itThe source system (where data is born)The local warehouse (where you analyze it)

The starter docker-compose.yml

Here's the clean, complete starter file for your mini-griddp project. It's just the Postgres service for now — but this file is the backbone of your platform, and you'll add services to it (a warehouse, an orchestrator, and more) as the curriculum grows.

mini-griddp/docker-compose.yml
# mini-griddp local stack
# Bring it up:   docker compose up -d
# Tear it down:  docker compose down
services:
  postgres:
    image: postgres:16
    container_name: griddp_postgres
    environment:
      POSTGRES_USER: griddp
      POSTGRES_PASSWORD: griddp_pw
      POSTGRES_DB: source
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:          # named volume: your database survives `down` and `up`
Commit it to git

This file defines real infrastructure, so it belongs in version control. From your mini-griddp folder: git add docker-compose.yml && git commit -m "Add Postgres compose service". Now anyone (including future you, on a fresh laptop) can recreate the exact same stack with one command. That's the reproducibility principle paying off.

Volumes & data persistence

Containers are designed to be stateless and disposable — you can throw one away and start a fresh one anytime. That's great for the software, but terrible for your data: when a container is removed, everything written inside it vanishes with it. Run docker compose down without a volume and your tables are gone.

A named volume solves this. It's a storage area Docker manages outside any single container. Our file maps the volume pgdata to the folder where Postgres keeps its data (/var/lib/postgresql/data), so the data lives in the volume, not the container. Tear the container down, bring it back up — the volume reattaches and your tables are still there.

✓ Prove it to yourself

Run docker compose down (removes the container), then docker compose up -d again, reconnect with docker compose exec postgres psql -U griddp -d source, and run SELECT * FROM customers;. Your three rows are still there — they lived in the pgdata volume the whole time. (If you ever truly want to wipe the data, docker compose down -v deletes the volumes too. Use that flag deliberately.)

This split — disposable compute in containers, durable state in volumes (and later, cloud storage) — is one of the most important patterns in modern data engineering. Keep it in mind.

✓ Check yourself

  • Can you explain the difference between an image and a container (hint: class vs instance)?
  • Can you bring a Compose stack up in the background and check its status?
  • Do you know why a named volume matters — what's lost without one?
  • Can you say which database is OLTP and which is OLAP, and what each stands in for?
Exercise — Stand up Postgres and query a GPU-rentals table

From your mini-griddp folder: (1) bring the stack up, (2) open psql, (3) create a gpu_rentals table, (4) insert 3 rows, and (5) query the total of an hours column. Try it from memory first.

terminal
cd ~/mini-griddp
docker compose up -d
docker compose exec postgres psql -U griddp -d source
psql (source=#)
CREATE TABLE gpu_rentals (
    id        INT PRIMARY KEY,
    gpu_model TEXT,
    hours     NUMERIC
);

INSERT INTO gpu_rentals (id, gpu_model, hours) VALUES
    (1, 'H100', 12.5),
    (2, 'A100',  8.0),
    (3, 'H100',  4.5);

SELECT SUM(hours) AS total_hours FROM gpu_rentals;

You should see total_hours = 25.0. If you got there, you can spin up a database, model a table, load it, and aggregate it — the everyday motions of working with a source system. Type \q to exit, and leave the stack running for the next chapter (or docker compose down to stop it; your data persists either way).

Next

You have a real database running and an analytics engine ready. Now let's connect them: pull data out of Postgres, transform it, and land it in DuckDB — your first end-to-end pipeline. → Your First Pipeline