The End of Migration Fakes
For years, Docker Compose users have wrestled with a common problem: how to run tasks like database migrations or seeding before the main application containers start. The typical workaround involved creating dedicated services for these tasks, often faked using depends_on with a condition, and then hoping users wouldn't notice these extraneous services when running docker compose ps -a. This pattern, while functional, was a kludge, adding unnecessary complexity to Compose files and obscuring the true application architecture.
Docker Compose 5.3, released in July, directly addresses this limitation with the introduction of the pre_start lifecycle hook. This new feature allows developers to define tasks that must execute and complete successfully before any other services in the Compose stack are started. It’s a long-awaited addition that simplifies Compose configurations and brings a more robust lifecycle management to containerized applications.
The author of the original Dev.to post spent an evening testing this new feature with a Postgres 18 container and a small Node.js application. The goal was to see if pre_start could handle real-world initialization scenarios and identify potential failure points. The results indicate that pre_start effectively replaces the old workaround, offering a cleaner and more explicit way to manage pre-application startup processes.
How `pre_start` Works
The pre_start directive is configured within a service definition. When a service has a pre_start block, Docker Compose will execute the commands specified within it before starting the main container for that service. Crucially, if any of the pre_start commands fail, the service will not start, and the entire Compose stack (depending on its configuration and other dependencies) may halt. This ensures that critical initialization steps are completed reliably.
Consider the common scenario of database migrations. Previously, a separate `migrate` service might have been defined like this:
services
app:
image: my-app
depends_on
:
- db
- migrate
environment
:
- DATABASE_URL=postgres://user:password@db:5432/mydb
db
image: postgres:18
environment
:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
migrate
image: my-migration-tool
depends_on
:
- db
command: "./wait-for-it.sh db:5432 -- /app/run-migrations.sh"
volumes
:
- ./migrations:/app
With pre_start, this can be simplified significantly. The migration logic is moved directly into the service that requires it to be run first, or a dedicated initialization service that explicitly runs before others. For instance, you could define the migration logic directly within the `app` service:
services
app
image: my-app
depends_on
:
- db
pre_start
:
- ./wait-for-it.sh db:5432 -- /app/run-migrations.sh
environment
:
- DATABASE_URL=postgres://user:password@db:5432/mydb
db
image: postgres:18
environment
:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
This new structure is cleaner. The `app` service now explicitly declares its prerequisite: database migrations must succeed before the application itself starts. The `db` service remains unchanged, as it is a fundamental dependency.
Real-World Scenarios and Considerations
The pre_start hook is versatile. It can be used for tasks like:
- Running database schema migrations.
- Seeding initial data into a database.
- Fetching configuration files or secrets from an external service.
- Performing health checks on dependencies before the main service starts.
A key aspect of pre_start is its sequential execution. If a service has multiple pre_start commands, they are executed in the order they appear in the YAML file. This is essential for complex initialization sequences where one step might depend on the completion of another.
The author's testing involved a Postgres container and a Node.js app. The Node.js app's pre_start command would attempt to connect to Postgres and run a migration script. If Postgres wasn't ready, or if the migration script failed, the Node.js container would not start. This behavior is precisely what’s needed for reliable application bootstrapping.
The surprising detail here is not the introduction of a new feature, but how long it took for such a fundamental lifecycle management tool to be integrated. Developers have been faking this functionality for years, highlighting a significant gap in Compose’s capabilities that is now finally closed.
If you run a development team using Docker Compose, you now have a much cleaner way to handle initialization tasks. The days of creating dummy services solely to orchestrate startup order are over. This simplification reduces boilerplate, improves readability of docker-compose.yml files, and makes the dependency graph of your services more explicit and accurate.
What’s Next?
The introduction of pre_start is a significant step forward for Docker Compose's lifecycle management. It brings parity with other container orchestration tools that have long supported similar pre-container start hooks. Developers can now rely on a native, declarative way to ensure their applications start in a known, valid state.
This feature is available in Docker Compose 5.3 and later. Users should update their Docker Compose installations to leverage this new capability. The impact on existing projects will be a reduction in complexity and a more robust deployment process, particularly for applications with critical initialization dependencies.
