Skip to content

Docker Compose: healthy is not a completed migration

Separate Docker Compose database health, successful migrations and application readiness. Rehearse schema errors, wrong credentials and runtime dependency loss.

Dopstack Engineering

· 9 min read

An original conceptual staircase separates server acceptance, schema completion and application readiness; a fictional mechanic carries a key toward the final step.

Quick answer

Docker Compose can wait for a healthy database and for a migration service to finish successfully, but these are different conditions. Gate the migration on database health, gate the application on migration completion, and check the application's own database access separately. Keep recovery behavior in the application because dependency ordering does not supervise every later failure. [1][2]

Docker Compose can wait for a healthy database and for a migration service to finish successfully, but these are different conditions. Gate the migration on database health, gate the application on migration completion, and check the application's own database access separately. Keep recovery behavior in the application because dependency ordering does not supervise every later failure. [1][2]

Scenario: an order service starts against a local Postgres database. Its database container is healthy, yet the service fails because the expected table is missing. Increasing the healthcheck delay hides the failure on one machine and makes startup slower on another. The application needs the schema job's successful exit before it starts.

This is an illustrative deployment exercise, not a customer incident. Its acceptance criteria are specific: the schema job must stop on a SQL error; the application must remain blocked when that job fails; and a later database interruption must produce the application's documented recovery behavior. A green database icon cannot establish all of those outcomes.

What does the database healthcheck actually prove?#

A healthcheck proves only what its command tests at the time it runs. Docker maintains a health status alongside the container's ordinary process status. The runtime can report that a process is running while its health command fails. Compose can use that health result as a dependency condition. [2][3]

For Postgres, pg_isready checks connection status. It does not need a correct database name, username or password to report the server's status. That makes it useful for an availability gate and insufficient as proof that the application can authenticate and query its schema. [4]

Name the boundary in the probe description. “Postgres accepting connections over TCP” is a useful statement. “Application ready” would be misleading if the command never uses the application account or checks a required relation. A probe's name should make its limitations easy to see during an incident.

The short depends_on form establishes order, while the long form can require health or successful completion. Those conditions should follow the actual dependency graph. A database is a long-running service; a schema job is a task that should exit. Waiting for the schema job to become a healthy, permanently running process would model the wrong lifecycle. [1][2]

Separate the evidence into three records: server health, schema-job exit result and application readiness. The application record may refer to the other two, but it should not replace them. When startup fails, that separation tells the operator whether to inspect connection establishment, migration logs or application credentials.

Put the schema job between the database and application#

Make the migration a one-shot service whose success is meaningful. The illustrative fragment below uses a repository-owned schema file. It intentionally stops short of supplying an application image: connect these services to your existing application and its reviewed credential mechanism. This is a development rehearsal, not a production deployment package. [2][5]

example.yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: rehearsal
      POSTGRES_PASSWORD: local-example-only
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 20s

  migrate:
    image: postgres:18
    restart: "no"
    depends_on:
      db:
        condition: service_healthy
    environment:
      PGPASSWORD: local-example-only
    volumes:
      - ./schema.sql:/work/schema.sql:ro
    command:
      - psql
      - -h
      - db
      - -U
      - rehearsal
      - -d
      - orders
      - -v
      - ON_ERROR_STOP=1
      - -f
      - /work/schema.sql

  # Merge this dependency into your existing application service:
  # app:
  #   depends_on:
  #     migrate:
  #       condition: service_completed_successfully

The password is a conspicuous local example. Use an isolated project, avoid exposing the database port, and replace the credential arrangement before adapting the file to a real environment. The changing image tag is also a rehearsal convenience; pin and record the tested image according to your deployment policy.

The critical setting is the migration command's error behavior. By default, psql can continue processing after an error. Setting ON_ERROR_STOP makes the script stop instead. A migration task that logs a failed statement and later exits successfully defeats the dependency gate. Confirm exit status, rather than searching logs for a reassuring final line. [5]

Use a small schema file for the exercise: create the table the application needs and insert a labelled fixture row. The fixture is evidence of the path through the deployment, not business data. Keep the real migration tool's transaction, locking and version-history behavior in its own test plan. A successful psql file is not a substitute for that tool's migration semantics.

Do not attach an automatic restart loop to a failed one-shot migration merely to make the stack eventually green. Repeating schema operations can have different consequences from retrying a connection. Diagnose the failed statement, determine whether it partly applied, and resume according to the migration tool's documented recovery procedure.

Why can the green database still reject the application?#

Because server acceptance, successful authentication and schema compatibility are different tests. A server can accept connections while the application's configured role is missing. A migration can succeed under its privileged identity while the runtime account lacks permission to read the resulting table. The healthcheck above does not exercise either condition. [4]

For the order service, add a readiness operation that runs through the same connection pool and account used by ordinary requests. It should perform a cheap, narrowly scoped query against a required object. A generic SELECT proves connection execution; querying the relation the application needs adds evidence of schema and permissions. Choose the operation according to the service's actual dependency.

Avoid making the database's own healthcheck depend on a table that only the migration job creates. That would create a cycle: the migration waits for healthy, while healthy waits for the migration. Keep the server probe independent of schema installation, then assess schema compatibility from the layer that requires it.

Likewise, do not give the application the migration role simply because it makes the readiness query pass. That changes the application's permissions to repair an observation problem. Keep a deliberate separation between installation privileges and runtime access, and test the actual runtime account before opening traffic.

An application's schema check should also distinguish missing prerequisites from transient availability. A missing column is unlikely to be repaired by repeatedly opening a connection. Report a clear incompatible-schema state and preserve the evidence needed to identify the required migration. Retry only the conditions for which retry is part of the chosen recovery design.

What happens after the application starts?#

Startup ordering does not supply continuous dependency supervision. The restart: true option inside a long-form dependency concerns explicit Compose-controlled restarts; the services reference excludes automatic runtime restarts after a container dies. It is separate from the service-level restart policy. [1][2]

That difference belongs in the runbook. “Restart the database with Compose” and “the database process crashes” are distinct experiments. The application should handle lost connections according to its own request deadlines and pool behavior. An old pooled socket may fail even after the server accepts new connections again.

Decide what happens to requests during the interruption. A read may return a temporary failure. A write with an uncertain outcome needs reconciliation before blind retry. The transactional outbox guide addresses a related boundary: preserving a business change and its eventual event. Neither a healthcheck nor a restart policy makes a business operation safely repeatable.

Keep the application's recovery bounded. A process that spins indefinitely during initial connection failure can make diagnosis harder than a process that reports its blocked state. Conversely, a process that exits on every transient dependency error may create avoidable restart churn. Select the behavior from the serving platform and the request contract, then rehearse it.

A schema release also needs a concurrency decision. If several application replicas each start a migration job, the healthy database gate does not elect a single schema owner. Use the migration tool's documented locking or the deployment system's release coordination. The dependency graph only states what this Compose project waits for; it does not coordinate every deployment that can reach the same database. [2]

Keep rollback expectations concrete. Stopping the application after a failed schema job is an admission decision, not an automatic schema rollback. An operator still needs to know whether the tool applied an earlier statement and whether the previous application version remains compatible. Preserve the migration identity and its output so recovery does not depend on guessing from the latest container status.

For a shared development database, scope the rehearsal to a separate database and project name. A clean cold-start experiment is useful only if its cleanup cannot remove someone else's data. Record which resources belong to the fixture and which were supplied by the developer before running any teardown.

Which failure should each gate expose?#

Use a failure matrix before tuning time values. Change one input at a time so the result identifies a mechanism. The proposed checks below are our rehearsal method, not Docker's certification procedure.

Deliberate changeExpected evidenceWhat must remain blocked
Server not accepting connectionsDatabase health remains unsuccessfulMigration and dependent application
Invalid SQL in schema jobNon-successful migration exit and statement errorApplication startup
Wrong runtime credentialsApplication readiness failureTraffic requiring database access
Database loss after startupApplication recovery or bounded failureUnsafe business retries
Decision and verification record

Start with a fresh disposable database for the cold-start case. A previously populated volume can hide a missing migration because the required table already exists. Keep a separate warm-start case so the test suite covers both an empty installation and an ordinary restart. Never delete a real volume to make a tutorial's output look clean.

Next, put an invalid statement before the required table creation. Confirm the schema task exits unsuccessfully and the application does not start through its dependency gate. Then repair the file and repeat using the migration tool's intended recovery path. Record whether the earlier attempt left any changes; do not assume all migration systems wrap everything in a transaction.

The credential case comes after a successful schema job. Supply the application with an intentionally invalid rehearsal credential and verify that its own readiness check fails. Leave the database's healthcheck alone. If the database becomes unhealthy merely because this application credential is wrong, the probes no longer describe separate boundaries.

Finally, interrupt the dependency after startup in the isolated stack. Observe application logs, readiness and request outcomes, then restore the database. Verify recovery without restarting unrelated services by hand unless manual restart is the documented policy. This is the point where a startup-only tutorial often stops and the operational contract begins.

Compose's up --wait option can wait for services to reach running or healthy states and accepts a wait timeout. Use it to bound automation, while retaining the distinction between process state, declared health and application checks. Its success cannot prove an operation your configured checks never attempted. [6]

Frequently asked questions#

Should the migration service stay running and healthy?
A one-shot migration should normally exit with a meaningful result. Compose's successful-completion condition fits that lifecycle. A permanently sleeping migration container can hide whether the operation finished, failed or never ran. Keep any long-running migration controller under its own explicit contract. [2]
Does pg_isready validate my application password?
It checks server connection status, and PostgreSQL documents that correct user, password and database values are not required for that status check. Test the runtime account with the application's own connection path when authentication or schema access is part of readiness. [4]
Can a longer start period fix missing tables?
Only if the delay happens to conceal the missing dependency, which leaves the deployment unreliable. A schema job should report successful completion, and the application should depend on that result. Timing parameters should reflect startup behavior rather than stand in for migration evidence.
Should every dependency failure restart the application?
That depends on the application's connection handling and the platform's supervision model. Test runtime loss separately from explicit Compose restarts. Specify whether the application reconnects, reports temporary unavailability or requires a restart; do not infer that policy from depends_on alone. [1][2]

Technologies

  • Docker Compose
  • PostgreSQL

Sources

  1. [1]
    Control startup and shutdown order in Compose, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

  2. [2]
    Compose services reference, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

  3. [3]
    Dockerfile HEALTHCHECK reference, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

  4. [4]
    PostgreSQL18 pg_isready, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

  5. [5]
    PostgreSQL18 psql, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

  6. [6]
    Docker Compose up reference, Docker / PostgreSQL

    Observed28October2026; relevant source text saved in source-observations. Dates not listed unless specifically recorded.

Written by

Dopstack Engineering

Engineering team

The engineering team at Dopstack. Illustrative scenarios are composites written to explain a pattern, not accounts of client work.

All articles Website

Get new articles by email

Engineering notes on AI, cloud and SaaS. Occasional, never sold or shared.

Send me new Dopstack Insights articles by email. I can unsubscribe at any time. We’ll email you once to confirm. Privacy

Continue reading