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

  1. Compose reads the file and chooses a project name, often from the directory name.
  2. Compose creates the default project network unless you declare custom networks.
  3. Compose creates the pg-data named volume if it does not exist.
  4. Docker creates the database container from the postgres:16.4-alpine image. The image layers are read-only; the container’s writable layer is disposable.
  5. Docker mounts pg-data at /var/lib/postgresql/data, so database files are written to persistent storage instead of only to the container layer.
  6. Docker starts the PostgreSQL process with environment variables such as POSTGRES_DB and POSTGRES_PASSWORD. The password shown here is an obvious placeholder for local learning.
  7. Docker creates the app container and attaches it to the same network as db.
  8. When the app resolves hostname db, Docker DNS returns the database container’s current network address.
  9. The app opens a TCP connection to db:5432 and authenticates using the configured username, password, and database name.
  10. If the database container is removed and recreated, the volume remains and the service name db still 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 changeme in examples, never real-looking secrets.
  • Keep real secrets out of Dockerfiles and image layers.
  • Pin image tags such as postgres:16.4-alpine and node:20-alpine instead of using latest.
  • 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 EXPOSE is metadata only. It does not publish a port to the host; only ports in Compose or docker run -p does that.

Practice Exercises

  1. Create a Compose file for an API and PostgreSQL. The API should use DATABASE_URL with hostname db, and PostgreSQL data should survive docker compose down. Hint: use a named volume.
  2. Modify a database service so a host-side GUI can connect on port 5433, while the app still connects to db:5432. Expected end state: host tools use 127.0.0.1:5433; the app does not.
  3. Add a PostgreSQL health check with pg_isready. Expected end state: docker compose ps eventually 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.
  • localhost inside the app container points to the app container itself.
  • Named volumes keep database files outside the replaceable container writable layer.
  • depends_on controls 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.