docker compose: App Plus Database
Your app runs in a container. Now it needs a database - and a database is its own piece of software, with its own version and config and storage. You could install Postgres on your machine, but that drags you right back to "works on my machine." Instead you run Postgres as a second container.
Two containers means two docker run commands with the right ports, names, networks, and environment variables - and you'd type them in the right order every time. That gets old fast. Docker Compose is the fix: you describe the whole stack once in a file, and bring it all up or down with one command.
Give the app something to store
First, make the app actually use a database so we can see it working. Update requirements.txt:
# requirements.txt
flask==3.0.3
psycopg[binary]==3.2.1
psycopg is the Postgres driver for Python; the [binary] extra pulls a prebuilt version so nothing needs compiling. Now update app.py to count visits in Postgres:
# app.py
=
=
=
return f
Notice the app reads its database connection from os.environ["DATABASE_URL"]. It doesn't hard-code where the database is - that comes from the environment, which is exactly what lets the same image run against different databases. Compose will set that variable for us.
The compose file
Create compose.yaml next to your Dockerfile:
# compose.yaml
services:
web:
build: .
ports:
- "8080:5000"
environment:
DATABASE_URL: postgresql://appuser:secret@db:5432/appdb
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
Walk through it:
- Two services,
webanddb. Each becomes a container. build: .-webis built from your Dockerfile in the current folder.dbinstead pulls the officialpostgres:16image - no Dockerfile needed.portsonwebdoes what-pdid: maps host 8080 to container 5000.environmentsets variables inside each container. Thedbservice uses Postgres's own variables to create a user, password, and database on first start. Thewebservice gets theDATABASE_URLits code reads.depends_on: [db]tells Compose to startdbbeforeweb.volumesondbmaps a named volumedbdatato where Postgres stores its files, so data survives a container restart. More on that below.
The magic word: db
Look closely at the connection string: postgresql://appuser:secret@db:5432/appdb. The host is db - the name of the service. Compose puts every service on a shared private network and lets them find each other by service name. Inside the web container, db resolves to the Postgres container's address. You never deal with IP addresses.
flowchart LR
H[Your browser<br/>localhost:8080] --> W[web container<br/>Flask :5000]
W -- "connects to host 'db'" --> D[db container<br/>Postgres :5432]
D --- V[(dbdata volume)]
Note that 5432 is not published to your host - only web can reach Postgres, over the private network. That's a sensible default: your database shouldn't be exposed to the outside world. (If you want to inspect it with a desktop tool, you can add a ports entry to db, but you don't need to.)
Volumes: why your data survives
Containers are disposable. Delete one and everything written inside it is gone. That's fine for your stateless app, but a database that forgets everything on restart is useless.
A volume is storage that lives outside the container's lifecycle. The line dbdata:/var/lib/postgresql/data says "keep the contents of Postgres's data directory in a named volume called dbdata." Destroy and recreate the db container and the data is still there, because it never lived inside the container - it lived in the volume.
Bring it up
From the project folder:
up starts the whole stack; --build rebuilds your web image first so code changes are picked up. You'll see logs from both services interleaved, color-coded by service. Wait for Postgres to report it's ready and Flask to say it's serving, then hit it:
# You are visit number 1
# You are visit number 2
The count climbs because each request writes a row to Postgres. Stop the stack with Ctrl+C, or run detached and manage it separately:
Prove the volume works
Here's the satisfying test. Bring the stack down - but keep the volume:
# You are visit number 3
The count picked up where it left off. down removed the containers, but the dbdata volume persisted, so Postgres still had your rows. Now wipe it for real with -v, which deletes the named volumes too:
# You are visit number 1
Back to one - the data is gone because you destroyed the volume. That -v flag is the difference between "restart the stack" and "start fresh," and knowing which is which will save you a panic someday.
You now have a real two-container application defined in one file, brought up and down with one command, with data that persists exactly as long as you want it to. The only thing left is making it fit to hand to someone else - secrets, health, and a registry. That's Phase 4.