In an open-source project, “it works on my machine” is usually not a technical success. It signals a gap in documentation and the environment contract. If a new developer has to guess which version to install, which service to start, how to prepare the database or where a secret value comes from after cloning the repository, the project is not easy to set up yet.
Docker Compose does not solve this by itself. A compose.yaml file can start a few containers, but a repeatable development environment turns dependencies, configuration, data lifetime and the first-run flow into an explicit contract. The test is simple: on a clean machine, can the application become usable through a few documented steps?
Design the starting experience
The first job of a development environment is not to describe a working application. It is to bring someone starting from zero to that application safely. A good startup flow usually follows this order:
- Clone the repository and copy the example configuration.
- Fill in any explicitly required local values.
- Start the application and dependencies with one command.
- Confirm that the system is ready through a health check or short verification command.
- When test data is needed, use an optional seed command that is safe to run again.
Every manual step in this flow deserves scrutiny. Defining a database as a service is more consistent than asking someone to install PostgreSQL separately. Putting migrations in startup or in an explicit task command is safer than instructing a contributor to run a SQL file once by hand.
Separate services by purpose
Not everything has to be a separate container. The application, database, cache, queue, object storage or email test tool become useful as distinct services when the separation makes dependency versions, persistent data and network access visible.
Conversely, services split merely to run one process can make development harder. The measure is not technical purity but the daily workflow. Can a developer refresh application code without restarting a dependency? Can the frontend find the backend at a consistent local address? Can a test reach what it needs without requiring the public internet?
Names should describe function rather than infrastructure detail. Names such as api, web, db, cache and worker also become understandable DNS names on the Compose network.
Separate configuration from secrets
Ports, log level, enabled features and local URLs can be configured with environment variables. Real access keys, passwords and production connection strings must never enter the repository. Instead, an .env.example file should provide a safe template that explains every required variable.
The example should be a runnable starting point, not only a list of blanks. Values for locally hosted services can have useful defaults, while values that require a person’s own provider account should be marked clearly. The startup command should report missing required variables in understandable language.
It is common for the same setting to appear under different names in the Compose file and application configuration. Documenting names and responsibility in one place reduces small but expensive questions such as which service owns a port.
Separate readiness from startup order
A started container does not necessarily mean that an application is ready to accept requests. A database process can be running without accepting connections, migrations may still be in progress, or a worker may not yet be connected to its queue.
Use health checks rather than relying only on startup order. Is the database really accepting queries? Does the API answer its health endpoint after migrations? Dependent services should wait for healthy states where possible. The application also needs its own retry logic; a Compose health check should not be the only defence against temporary network failures.
When a first run fails, logs must be readable. Beyond “connection refused”, a contributor should understand which dependency was expected, which connection information was used and what the next safe step is.
Manage data lifetime deliberately
Deleting database and file-storage data on every down command wastes time and creates a false sense of safety. Named volumes keep data separate from a container lifecycle. That persistence must be documented: what does a clean-environment command delete, can seed data run again, and can a migration be rolled back?
Development and test data should be separated from real personal data. A local sample dataset must not be a copied production backup. Anonymised or synthetic records are easier to share and make demonstrations and test flows safer.
Use profiles for optional dependencies
Not every developer needs a search engine, message queue, object storage or email catcher at the same time. Compose profiles keep the basic environment light while enabling extra services for particular features.
For example, a default command can start api, web and db, while integration tests add a queue and mail profile. This distinction should be visible in the documentation too. If a feature depends on a profile, setup guidance is better than an unexplained interface error.
Verify documentation with commands
Every setup command in a README should regularly run in a real environment. The strongest test is following the quick-start steps from a fresh clone and clean volumes. This exposes missing .env variables, forgotten migrations and hidden assumptions about external tools.
A Compose file is not the whole development environment. But when designed well, it greatly lowers the first-contribution barrier. Clear service boundaries, safe configuration, health checks, deliberate data lifetime and verified documentation turn “it works” from a personal condition into a repeatable promise.