In previous chapters, your Spring Boot application ran in a container, but every database call would fail. Inside a container, localhost is the container itself and it does not have Postgres installed on it. So your app was looking for PostgreSQL in the wrong place.
You could fix this by hand, with Docker network commands and several long docker run commands that you have to type in the right order, every time. Nobody does that. Instead, you describe your whole local setup โ the app and the database โ in one file, and start it with one command. That tool is Docker Compose, and it is already included in Docker Desktop.
https://www.youtube.com/watch?v=f3xb7kt_dH4
Create a file called compose.yaml in the root of your project, next to your Dockerfile. (You will also see the older name docker-compose.yml in many projects โ both work.)
<aside> ๐ญ
Compose files are written in YAML, which you used for GitHub Actions in the core program. Rusty? Review Appendix 1: YAML syntax.
</aside>
Here is a complete file for a Spring Boot API with a PostgreSQL database:
services:
api:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/mydb
SPRING_DATASOURCE_USERNAME: dev
SPRING_DATASOURCE_PASSWORD: dev
depends_on:
- db
db:
image: postgres:18
environment:
POSTGRES_DB: mydb
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql
volumes:
db-data:
Walking through it:
services: โ each entry becomes one container. Here: api and db.build: . โ the api image is built from the Dockerfile in this folder (your multi-stage Dockerfile from the previous chapter).image: postgres:18 โ the db image is pulled ready-made from Docker Hub. Those are the two ways to get an image: build it yourself or use an existing one.ports: โ the same port mapping you did with -p, in YAML form.environment: โ the same variables you passed with -e. The POSTGRES_* variables tell the Postgres image which database and user to create on first start.depends_on: โ start db before api. Note: this controls the start order only. Learn more in Healthchecks below.volumes: โ explained in a moment.<aside> โ
Look at the SPRING_DATASOURCE_* variables. Spring Boot automatically matches environment variables to properties: SPRING_DATASOURCE_URL overrides spring.datasource.url. So your application.properties keeps localhost for running the app directly on your machine, and the Compose values override it inside the container. Same image, different configuration โ this idea carries the rest of the week.
</aside>
<aside> โ
Pin versions like postgres:18 instead of using latest. With latest, your project can break the day a new major version is released.
</aside>
db becomes an addressWhen Compose starts your stack, it creates a private network for the project and connects every service to it. On that network, each service name is a hostname: Docker's built-in DNS makes db resolve to the database container.
That is why the datasource URL says jdbc:postgresql://db:5432/mydb. This is the fix for the previous chapter's problem: between containers you use the service name, never localhost.
And the ports: "5432:5432" on the db service? That is only for you: it lets the tools on your machine (psql or a GUI tool) connect to localhost:5432 and look inside the database. The api container does not need it.
<aside> โจ๏ธ
Hands on: Delete the ports: lines from the db service and run the stack again. Does the API still reach the database?
In the previous chapters you mounted a folder from your machine with -v (a bind mount). The db-data volume here is a named volume: storage that Docker creates and manages for you. Named volumes are the standard choice for databases.
It is declared once at the bottom under volumes: and mounted into /var/lib/postgresql โ the folder where PostgreSQL stores its data. Result: your data survives containers being stopped, removed and recreated.
<aside> โ ๏ธ
docker compose down keeps your volumes. docker compose down -v deletes them โ that flag wipes your database. Use it only when you want a truly fresh start.
</aside>
To build and run all the containers, run the following command:
docker compose up
Docker pulls the Postgres image, creates the network and the volume, then starts both containers. Your API and your database in one command.
Other useful docker compose commands:
docker compose up # build if needed, start everything, stream all logs
docker compose up -d # the same, but in the background
docker compose ps # what is running in this project
docker compose logs -f api # follow the logs of one service
docker compose down # stop and remove containers + network (volumes stay)
<aside> โ
The modern command is docker compose (with a space). Many older tutorials show docker-compose (with a hyphen) - that is the legacy version. If a tutorial uses the hyphen, you can remove the hyphen when following the tutorial.
</aside>
<aside> โ ๏ธ
Changed your Java code but the container behaves like before? Compose reuses the existing image โ it does not rebuild automatically. Run docker compose up --build after code changes.
</aside>
The PostgreSQL in your container is brand new โ it has none of your tables. Two ways to load your schema:
psql to localhost:5432 (that is what the published port is for) and run your schema SQL..sql file it finds in /docker-entrypoint-initdb.d/ โ once, on the very first start with an empty volume. Mount your schema file into it:services:
db:
volumes:
- db-data:/var/lib/postgresql
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
<aside> ๐ก
Added the init script but nothing happened? It only runs when the volume is empty. Your db-data volume already contains data from an earlier start โ run docker compose down -v first (this deletes the old data), then docker compose up again.
</aside>
Docker Compose is the standard way developers run a full stack locally: app, database and anything else, up in one command, identical on every teammate's machine.
In production it is a different story. Most companies run containers on managed cloud platforms or orchestrators (such as Kubernetes), which handle things Compose does not: multiple machines, automatic restarts and scaling. On a small scale โ one server running one application โ Compose is a perfectly valid production choice too.
For you this week, the split looks like this: the Compose file stays on your machine for development, and in the cloud your app becomes a single deployed container that connects to a managed database service. You will see exactly that in the Deploying to the Cloud chapter.
<aside> ๐ญ
This section cover an advanced topic, it is optional to read.
</aside>
Sometimes docker compose up may show the api crashing with a database connection error โ and running it again works fine. What is going on?
depends_on controls the start order, not readiness. PostgreSQL was started first but was still initialising when the API tried to connect. Restarting works because by then the database is ready.
Give the database a healthcheck and make api wait for it to pass:
services:
db:
image: postgres:18
# ...same as before...
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d mydb"]
interval: 2s
timeout: 2s
retries: 10
api:
build: .
# ...same as before...
depends_on:
db:
condition: service_healthy
Now Compose starts the API only after PostgreSQL reports that it is ready to accept connections.
compose.yaml can contain.POSTGRES_* variables and the init-script mechanism, documented by the image maintainers.The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.