ToolsOps

Docker Compose healthchecks: depends_on, secrets and .env

How to write correct Docker Compose healthchecks, use depends_on with condition: service_healthy, manage secrets with .env.example and avoid exposing database ports.

Modern Compose: no version:

In Docker Compose v2 the version: key is obsolete. Compose ignores it and emits a warning. Modern files start directly with services: and, when needed, declare volumes: and networks: at the same level.

Per-service healthchecks

A healthcheck tells Docker whether a service is truly ready, not just that the process started. It is the basis for depends_onto wait reliably. Commands reference the container's own variables with $$VAR: Compose turns $$ into a single $, which the container shell evaluates at runtime (where the service variable does exist).

  • PostgreSQL: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
  • MySQL: mysqladmin ping -h localhost -u $$MYSQL_USER -p$$MYSQL_PASSWORD
  • Redis: redis-cli ping (with -a "$$REDIS_PASSWORD" when a password is set)
  • HTTP / web: wget --no-verbose --tries=1 --spider http://localhost/ || exit 1

depends_on with condition: service_healthy

The short form of depends_on (a list) only guarantees start order. To wait until the dependency is healthy, use the long form:

services:
  app:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

Secrets and .env.example

Never put literal passwords in compose.yaml: they end up in the repo and git history. Use ${VARIABLE} in environment and define values in a .env that is not committed. Version only a .env.example with placeholders like CHANGE_ME to document which variables are required.

Database ports: local vs production

Publishing 5432:5432 or 3306:3306 exposes the database to the whole host network. In development, if you need host access, bind to loopback (127.0.0.1:5432:5432). In production, keep the database on the internal Compose network and do not publish its port: other services reach it by name.

Volumes and backups

Database data should live in a named volume (for example postgres_data:/var/lib/postgresql/data) so it survives docker compose down. A volume is not a backup: schedule periodic dumps (pg_dump, mysqldump) and store them off-host. For Nginx as a reverse proxy, mount the config read-only (./nginx.conf:/etc/nginx/nginx.conf:ro).

Stacks with an admin panel

The PostgreSQL + pgAdmin and MySQL + phpMyAdmin stacks add a web interface to manage the database. They are handy in development, but the panel is an attack surface: it grants full access to your data.

The database does not need to publish its port: the panel reaches it over the internal Compose network (by service name). Publish only the panel's port, and in development bind it to loopback (127.0.0.1:5050:80 for pgAdmin, 127.0.0.1:8080:80 for phpMyAdmin) rather than all interfaces.

In production, do not expose the panel unprotected: keep it behind a VPN or a reverse proxy with TLS and authentication, or do not deploy it at all. Panel credentials (PGADMIN_DEFAULT_PASSWORD, or the phpMyAdmin login) go through ${VARIABLE} in a .env kept out of git, never hardcoded in the compose file.

Next steps

Generate a starting compose.yaml and validate yours with the Docker Compose generator and validator. Build ready-to-paste healthchecks and check that your Compose does not expose ports or secrets by mistake.

Frequently asked questions

How do I write a healthcheck for Postgres in Docker Compose?
Use pg_isready inside the container itself: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]. The double $$ tells Compose to pass a single $ to the container, where POSTGRES_USER and POSTGRES_DB exist as the service's environment variables. Pair it with interval, timeout, retries and start_period.
Why doesn't depends_on wait for the database to be ready?
The short form of depends_on (a list of names) only waits for the container to start, not for the service to accept connections. To truly wait you need a healthcheck on the target service and the long form: depends_on with condition: service_healthy.
Where do I put the passwords in my compose.yaml?
In a .env file that is NOT committed. In compose.yaml reference variables with ${VARIABLE} and version only a .env.example with placeholders (for example CHANGE_ME). That way the real secret never enters git or its history.
Should I expose the database port?
In production, no: keep the database on the internal Compose network and connect from other services by name. In development, if you need host access, bind to 127.0.0.1 (127.0.0.1:5432:5432) so it is not open to the whole network.
Do I need the version: key in modern Compose?
No. In Docker Compose v2 the version: key is obsolete: it is ignored and triggers a warning. Start the file directly with services:.
Is it safe to use pgAdmin or phpMyAdmin with Docker Compose?
In development, yes, with care. The panel grants full access to the database, so locally bind it to 127.0.0.1 (for example 127.0.0.1:5050:80 for pgAdmin) and do not publish the database port: the panel reaches it over the internal Compose network. In production do not expose the panel without a VPN or a reverse proxy with TLS and authentication; often the best choice is not to deploy it. Panel credentials go through ${VARIABLE} in a .env kept out of git.