Connecting a Containerized App to a Database
Connecting a containerized app to a database means wiring two separate containers so the app can reliably find, authenticate to, and use the database. The important shift is that the app must connect across a Docker network, not to localhost inside its own container. Docker Compose makes this practical by giving services stable DNS names, shared networks, and persistent database volumes.
Overview: How App-to-Database Connections Work
A typical development stack has at least two containers: an application container and a database container. Each container is created from an image made of read-only layers, then Docker adds a thin writable layer and starts the container’s main process. Networking is isolated too: every container has its own network namespace, including its own loopback address. That is why localhost inside the app container means the app container itself, not the database container and not your laptop.
Docker solves this with networks. When services share a user-defined Docker network, Docker’s embedded DNS server lets them resolve each other by name. In Compose, the service name is the normal hostname. If your Compose file has services named api and db, the API should connect to host db on the database’s container port, such as 5432 for PostgreSQL or 3306 for MySQL. You do not need to publish the database port to the host for the app container to reach it.
The other half is persistence. Database files must not live only in the database container’s writable layer, because that layer disappears when the container is removed. A named volume mounted at the database image’s data directory keeps the data outside the replaceable container. For PostgreSQL that path is commonly /var/lib/postgresql/data. For MySQL it is commonly /var/lib/mysql.
Finally, connection timing matters. depends_on can start the database container before the app container, but it does not magically make the database ready to accept connections. Real apps should retry database connections during startup. Compose health checks can make local workflows clearer, but application-level retry logic is still the most portable answer.
Syntax
The common Compose structure is:
services:
app:
build: .
environment:
DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
depends_on:
- db
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
| Part | Meaning |
|---|---|
app |
The application service. It may use build: . for a local Dockerfile or image: for a prebuilt image. |
db |
The database service name. Other services on the same Compose network can use db as a hostname. |
DATABASE_URL |
A common environment variable format for application connection strings. The exact variable name depends on the app framework. |
postgres://postgres:changeme@db:5432/appdb |
Username, password placeholder, hostname, port, and database name in one URL. |
depends_on |
Starts the database service before the app service. It is startup ordering, not a complete readiness strategy. |
volumes |
Mounts Docker-managed persistent storage for database files. |
ports |
Publishes a container port to the host. It is optional for the database unless host tools need direct access. |
Useful commands while developing are:
docker compose up -d
docker compose ps
docker compose logs app
docker compose exec app printenv DATABASE_URL
docker compose down
Examples
Example 1: A Node App Connects to PostgreSQL
This Compose file builds an app image from the current directory and runs PostgreSQL beside it. The app uses db as the database hostname because db is the Compose service name.
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
depends_on:
- db
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
Start the stack and inspect it:
docker compose up -d
docker compose ps
Output:
NAME IMAGE SERVICE STATUS PORTS
shop-app-1 shop-app app Up 8 seconds 0.0.0.0:3000->3000/tcp
shop-db-1 postgres:16.4-alpine db Up 9 seconds 5432/tcp
The app is published to the host on port 3000, so a browser can reach it. PostgreSQL is not published to the host, but the app can still connect to db:5432 across the default Compose network. The pg-data named volume keeps database files when containers are recreated.
Example 2: Add a Health Check for Local Readiness
A health check gives Compose and humans a clearer signal about whether the database is accepting connections. This is useful for local development and troubleshooting.
services:
app:
build: .
environment:
DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
Check the service status:
docker compose up -d
docker compose ps
Output:
NAME IMAGE SERVICE STATUS PORTS
shop-app-1 shop-app app Up 3 seconds 3000/tcp
shop-db-1 postgres:16.4-alpine db Up 12 seconds (healthy) 5432/tcp
The app waits until the db service is healthy before Compose starts it. This is helpful, but it is not a substitute for retrying inside the app. Databases can restart, networks can briefly disconnect, and production orchestrators may handle dependencies differently.
Example 3: Allow Host Tools to Reach the Database
Sometimes a developer wants to connect with a host-side database GUI or CLI. In that case, publish the database port to the host, but keep the app’s connection string pointed at db.
services:
app:
build: .
environment:
DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
depends_on:
- db
db:
image: postgres:16.4-alpine
ports:
- "5433:5432"
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
From your host, test the published port with a temporary PostgreSQL client container:
docker compose up -d
docker run --rm --network host postgres:16.4-alpine pg_isready -h 127.0.0.1 -p 5433 -U postgres
Output:
127.0.0.1:5433 - accepting connections
The host uses port 5433 because ports: "5433:5432" maps host port 5433 to container port 5432. The app still uses db:5432. This avoids routing internal app traffic through the host and prevents conflicts with a PostgreSQL server that may already be running on host port 5432.
How It Works Step by Step
- Compose reads the file and chooses a project name, often from the directory name.
- Compose creates the default project network unless you declare custom networks.
- Compose creates the
pg-datanamed volume if it does not exist. - Docker creates the database container from the
postgres:16.4-alpineimage. The image layers are read-only; the container’s writable layer is disposable. - Docker mounts
pg-dataat/var/lib/postgresql/data, so database files are written to persistent storage instead of only to the container layer. - Docker starts the PostgreSQL process with environment variables such as
POSTGRES_DBandPOSTGRES_PASSWORD. The password shown here is an obvious placeholder for local learning. - Docker creates the app container and attaches it to the same network as
db. - When the app resolves hostname
db, Docker DNS returns the database container’s current network address. - The app opens a TCP connection to
db:5432and authenticates using the configured username, password, and database name. - If the database container is removed and recreated, the volume remains and the service name
dbstill resolves to the new container endpoint.
Common Mistakes
Using localhost in the App Container
Wrong:
environment:
DATABASE_URL: postgres://postgres:changeme@localhost:5432/appdb
This makes the app look for PostgreSQL inside the app container. Fix it by using the database service name:
environment:
DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
Forgetting Persistent Storage
Wrong:
services:
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: changeme
This runs, but database files live in the container unless a volume is mounted. Removing the container can remove your data. Fix it with a named volume:
services:
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: changeme
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
Baking Secrets into an Image
Wrong:
FROM node:20-alpine
ENV DATABASE_URL=postgres://postgres:realpassword@db:5432/appdb
Image layers are durable history. A later RUN rm does not remove a secret from an earlier layer. Pass local placeholders through Compose environment variables, and use Docker secrets or your orchestrator’s secret store for real credentials.
Assuming depends_on Means Ready
depends_on is useful, but startup order is not the same as readiness. A database process may be running while migrations, recovery, or initialization are still in progress. Add application retry logic and use health checks when they improve the local workflow.
Best Practices
- Use Compose service names, such as
db, as database hostnames from other containers. - Use the database container port in connection strings, such as
5432, not a host-published port. - Publish the database port only when host tools need it; internal app traffic does not need
ports. - Mount a named volume at the database data directory for persistent development data.
- Use obvious placeholder passwords such as
changemein examples, never real-looking secrets. - Keep real secrets out of Dockerfiles and image layers.
- Pin image tags such as
postgres:16.4-alpineandnode:20-alpineinstead of usinglatest. - Make the application retry database connections at startup and after transient failures.
- Use
docker compose logs db,docker compose ps, and database client tools to debug connection failures. - Remember that
EXPOSEis metadata only. It does not publish a port to the host; onlyportsin Compose ordocker run -pdoes that.
Practice Exercises
- Create a Compose file for an API and PostgreSQL. The API should use
DATABASE_URLwith hostnamedb, and PostgreSQL data should survivedocker compose down. Hint: use a named volume. - Modify a database service so a host-side GUI can connect on port
5433, while the app still connects todb:5432. Expected end state: host tools use127.0.0.1:5433; the app does not. - Add a PostgreSQL health check with
pg_isready. Expected end state:docker compose pseventually shows the database as healthy, and the app still has retry logic.
Summary
- A containerized app should connect to a database by Docker network name, usually the Compose service name.
localhostinside the app container points to the app container itself.- Named volumes keep database files outside the replaceable container writable layer.
depends_oncontrols startup order, while health checks and app retries handle readiness more reliably.- Host port publishing is optional for databases and is mainly for host-side tools.
- Secrets should not be baked into Dockerfiles or image layers.
