Skip to content
shellhacks.dev
Docker

How to Wait for a Service in an Entrypoint Script Properly

Wait for a database or other service in a Docker entrypoint script with a bounded loop, real readiness checks, healthchecks in Compose and no sleep guesses.

6 min read
Summarize in AI
Illustration for "How to Wait for a Service in an Entrypoint Script Properly": a shell script in an editor.

An application container starts in a second and the database in ten. Without a wait, the app crashes on its first connection and either restarts in a loop or stays down. The fix is a bounded readiness loop, and the design has more pitfalls than it appears. It extends running a script on container start. The examples assume Compose v2 and Postgres 17.

A bounded wait loop

Shell
#!/bin/sh
set -eu

wait_for() {
  host=$1 port=$2 timeout=${3:-60}
  i=0
  until nc -z "$host" "$port" 2>/dev/null; do
    i=$((i + 1))
    if [ "$i" -ge "$timeout" ]; then
      echo "timed out waiting for $host:$port after ${timeout}s" >&2
      return 1
    fi
    sleep 1
  done
  echo "$host:$port is accepting connections"
}

wait_for db 5432 60
exec "$@"
Text
db:5432 is accepting connections

The loop polls once a second, stops after the limit and exits non-zero, so Docker marks the container as failed and the problem shows in logs. Alpine's BusyBox includes a basic nc; Debian slim images may need netcat-openbsd installed. In pure Bash, : < /dev/tcp/db/5432 works without extra packages but not in sh.

A port is not readiness

Shell
until pg_isready -h db -p 5432 -U app -d appdb -q; do
  sleep 1
done

A database may accept TCP connections while still recovering or while the proxy in front of it has no backend. Service-specific checks answer the real question: pg_isready for Postgres, mysqladmin ping for MySQL, a curl -f on a health URL for HTTP services. More on those in readiness checks for databases.

Let Compose do the waiting

YAML
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: change-me
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s

  app:
    image: myapp
    depends_on:
      db:
        condition: service_healthy

Compose starts app only after the database healthcheck passes. This moves the wait out of every application image, and the same health state is visible in docker compose ps. A plain depends_on: [db] only orders the start and does not wait for readiness, which is the source of many flaky startups.

Still retry in the application

A start-up wait covers the first connection only. Databases restart, networks blip and failovers happen hours later, so the application should reconnect with a delay between attempts. Treat the entrypoint wait as an optimisation that avoids noisy start-up errors, not as the reliability mechanism. When a wait script is the only mitigation, the system fails the first time the dependency restarts. See retries in scripts for backoff patterns.

Why the port opening is not enough

A TCP connection succeeding says the process is listening. It does not say the service is ready. A database may accept connections while it is still replaying its log and rejecting queries; a web application may bind its port before it has loaded its configuration and run migrations. Waiting on the port, as nc -z in a loop does, lets the dependent start early and fail with errors that look unrelated. A better check speaks the protocol: a query such as SELECT 1, the database's own readiness tool, or an HTTP request to a health route that returns success only when the service can do its job.

Choose the lightest check that proves the thing you depend on. For PostgreSQL, pg_isready tests the server's acceptance of connections, and a trivial query through the client library proves authentication and the target database exist. For HTTP services, request the health route and test the status code.

Bounds, backoff and clear failure

A wait loop without a limit is a hang. Choose a total timeout that exceeds the dependency's normal startup by a comfortable margin, and exit with a non-zero status and a clear message when it passes. Sleep between attempts, one to three seconds, so the loop does not hammer a service that is starting. Growing the delay a little each time, up to a cap, reduces load further without making the wait much longer. Print what you are waiting for and the attempt number occasionally, so the container log explains a slow start. On final failure, print the last error the check produced, since "timeout" alone sends the reader looking for the cause.

Make the timeout configurable by an environment variable. Startup time varies with hardware, and a CI machine under load takes longer than a laptop. A fixed constant eventually causes spurious failures on the slower machine.

Where the wait belongs

Waiting in the entrypoint is one option; orchestrators provide better ones. Compose supports depends_on with condition: service_healthy, backed by a health check on the dependency, and it blocks the dependent's start until the check passes. Kubernetes offers readiness probes and init containers, which keep traffic away and ordering explicit. These put the knowledge of readiness in the dependency's definition, which is where it belongs, and leave the entrypoint free of loops.

The entrypoint loop still has a role as a safety net when the platform gives no ordering, or when the dependency restarts while the application runs. In that case the application must tolerate connection failures at runtime with retries, since no startup check can protect against a database that restarts an hour later.

Making the application resilient anyway

The sturdy design assumes dependencies are sometimes missing. Connection pools should reconnect, clients should retry idempotent requests with backoff, and the service should report not-ready on its own health endpoint while it cannot reach what it needs, so a load balancer drains it instead of sending failures to users. Startup waits become a convenience instead of a requirement. Test this by stopping the database during a run in staging and watching what the application does. A service that recovers by itself when the database returns has far fewer midnight incidents than one that depends on being started in the right order.

Document the dependencies and their readiness checks near the Compose file or the deployment manifest, with the expected startup time of each. When someone adds a new service, that note tells them which checks to add. It also gives the person on call a reference when a deploy hangs: they can see which dependency is slow and what the application was waiting for, instead of reading shell scripts to infer the logic.

Remember the cost of getting this wrong in a restart loop. If the application exits when its dependency is missing and the platform restarts it immediately, the two together can generate a burst of connection attempts that makes a slow dependency slower. A short sleep before exit, or a restart policy with a delay, keeps the system calm while the dependency recovers, and the log shows a steady retry rhythm instead of a flood.

Where this does not apply

In Kubernetes, use readiness probes and init containers instead of entrypoint loops. Third-party scripts such as wait-for-it or dockerize exist and do the same job, but a ten-line function in your own entrypoint has no extra dependency to audit. Do not wait forever: an unbounded loop hides a misconfigured hostname behind a container that looks "starting" for hours. See the Compose startup order guide and the HEALTHCHECK reference.

Taggedscripting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.