docker compose logo

Writing Your First docker compose File: A Line-by-Line Walkthrough

docker compose FilePublished 21 October 2026 · Updated 28 October 20268 min readBy docker compose
Close-up of a developer editing a Compose YAML configuration on a laptop

The Compose file is the heart of every docker compose project. Once you understand how it is organised, the rest of the tool becomes remarkably intuitive. In this walkthrough we build a realistic file for a Node.js back end and explain the purpose of every line.

Choosing a file name

Compose looks for compose.yaml by default, and also accepts compose.yml, docker-compose.yaml and docker-compose.yml for backwards compatibility. The newer, shorter name is preferred for fresh projects. Place the file in the root of your repository so that relative paths resolve predictably.

Defining a service

Every entry beneath services becomes one or more containers. Consider the following definition:

compose.yaml
services:
  backend:
    build:
      context: backend
      args:
        - NODE_ENV=development
    command: npm run start-watch
    environment:
      - DATABASE_DB=example
      - DATABASE_USER=root
      - DATABASE_PASSWORD_FILE=/run/secrets/db-password
      - DATABASE_HOST=db
    ports:
      - "80:80"
      - "9229:9229"
    secrets:
      - db-password

build

The build block tells Compose to construct an image rather than pull one. The context is the directory sent to the builder, and args supplies build-time variables that the Dockerfile can read with ARG. Build arguments influence the image itself; they are not available to the running container unless the Dockerfile explicitly passes them on.

command

The command key overrides the image's default CMD. In development it is common to substitute a file-watching script so that the server restarts whenever source code changes.

environment

Runtime configuration belongs in environment. Notice that DATABASE_HOST is simply db: Compose provides built-in DNS so that each service name resolves to the correct container address. Passwords, however, should never be written in plain text. Pointing to a secret file, as above, keeps sensitive values out of the repository.

ports

Each entry follows the pattern HOST:CONTAINER. Port 80 serves the application, while 9229 exposes the Node.js inspector so that an IDE can attach a debugger. Always quote port mappings: unquoted values such as 22:22 can be misread by YAML parsers as base-60 numbers.

Adding the database and secret

compose.yaml (continued)
  db:
    image: mariadb:11
    environment:
      - MARIADB_DATABASE=example
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
    volumes:
      - db-data:/var/lib/mysql
    secrets:
      - db-password

volumes:
  db-data: {}

secrets:
  db-password:
    file: ./db/password.txt

The top-level secrets element declares where the secret originates, and each service opts in explicitly. Inside the container the value appears as a read-only file under /run/secrets/.

Validating your work

Before starting anything, run docker compose config. The command parses the file, resolves variables and prints the fully normalised configuration, revealing indentation mistakes and typos immediately. It is the single most useful habit a Compose author can adopt.

Treat the Compose file as code: review it, lint it and commit it alongside the application it describes.

Common beginner mistakes

  • Mixing tabs and spaces — YAML permits spaces only.
  • Using localhost to reach another service instead of the service name.
  • Committing real passwords in environment blocks.
  • Retaining an obsolete version: line and assuming it changes behaviour.

With these fundamentals in place, you can describe almost any development stack in a file that is short, readable and entirely reproducible.

Review the configuration as a coherent system

Before accepting a Compose file, examine the relationships between services rather than validating each block in isolation.

Use docker compose config to inspect the resolved model, then test startup against an empty development volume. A successful launch against yesterday’s database does not demonstrate that a new contributor can initialise the same environment from first principles.

Add a reliable startup relationship

A database container can be running before the database accepts connections. The check should test the actual dependency your application needs, rather than merely confirming that a process exists.

Choose health check timing with the real workload in mind. A database initialising a fresh volume can take longer than one reopening an existing database. Give it a reasonable start period, interval and retry count. An aggressive check can mark a healthy but slow startup as failed, while an overly generous check can make genuine failures harder to notice. Run the project once with an empty disposable volume and once with existing data. Those two cases expose different assumptions and are more informative than repeatedly restarting a stack that is already warm.

Keep development access intentional

When you publish a port, decide who needs to reach it. A development website often only needs access from your own computer, so a loopback binding such as 127.0.0.1:8080:80 can be appropriate. An internal database does not need a published host port just because the application uses it. Services on a shared Compose network communicate through their internal ports. Keeping those two paths separate makes the file easier to understand and reduces accidental exposure when the same laptop later connects to a shared network.

Apply the same reasoning to bind mounts. Mount the application directory that the development process needs, instead of granting broad access to unrelated host directories. Check whether a mount hides files that were installed into the image during the build. This often explains a missing dependency that appears only after the container starts. On systems with different host permissions, inspect the user that runs the application before changing ownership across the entire project. A narrow correction is easier to reason about than making every file writable to every process.

Separate values from the application structure

The Compose file should make service relationships obvious even when configuration values differ between developers. Use interpolation for nonsecret settings such as a local application port or a selected image tag. Provide an example environment file with harmless values and document which fields are required. A project-level environment file can supply interpolation values, but those values are not automatically passed into every container. Declare runtime variables through the service configuration when the application actually needs them. This keeps the difference between configuring Compose and configuring your program visible.

For passwords, use the mechanism supported by the application image. Declaring a Compose secret makes a file available to authorised services, but the application still needs to read that file. A variable ending in _FILE is an image convention rather than a universal Docker feature. Confirm support before copying the pattern into another service. Keep the original secret file outside version control and set sensible local permissions. The resulting configuration is easier to share because the service structure remains in the repository while each developer supplies private values separately.

Test the complete file from a clean checkout

Once the YAML validates, start the application and exercise a real feature. A successful home page is helpful, but a request that writes and reads a database record also tests service discovery, credentials and persistence. Inspect docker compose ps and the relevant service logs if the request fails. Change one thing at a time and rerun the same request. This gives you a repeatable signal that a configuration adjustment fixed the underlying issue rather than merely changing the error message.

Record the working startup command and the location of supporting files. Relative build contexts, bind mount paths and secret paths are common sources of differences between computers. Include any required seed or migration step in the setup instructions. Avoid relying on a database that was manually prepared on your own laptop. A Compose file becomes valuable when another person can use it without reconstructing your local history. The clean checkout exercise therefore tests the quality of the configuration more effectively than checking indentation alone.

Frequently asked questions

Should my Compose file include a version field?

Modern Compose uses the Compose Specification and does not need the old top-level version field to select a schema. For a new file, focus on supported service options and validate the result with the Compose plugin you intend to use.

Why does editing my Dockerfile not change the running app?

The running container uses an image that already exists. Rebuild the affected service and let Compose recreate its container from that image. A restart alone does not perform an image build or automatically incorporate newly copied source files.

Do I need to publish the database port?

Only if a client outside the Compose network needs direct access. The application can normally connect to the database service name and internal database port. Omitting the host mapping keeps the configuration simpler when all database clients are containers.

Where should I put the first Compose file?

The repository root is a common choice because it keeps paths and setup instructions easy to explain. Whatever location you choose, validate relative paths from the intended project layout and document any explicit file selection flags.

Conclusion

Writing your first docker compose file is an exercise in making application dependencies explicit. Define the services, choose deliberate port and storage mappings, provide configuration safely and verify a complete request. Keep the initial file readable, then add health checks or development conveniences when they solve a demonstrated problem. A small configuration that works from a clean checkout is a stronger starting point than a complicated file whose assumptions remain undocumented.