Docker Compose Syntax

Docker Compose Syntax: A Practical Reference Guide

Getting Docker Compose syntax right is the difference between a stack that starts cleanly and one that fails with a cryptic YAML parsing error at 2 AM. This guide walks through the core structure of a docker-compose.yml file, the most commonly misused directives, and practical patterns for keeping multi-container setups readable and maintainable. Whether you’re defining your first service or debugging an indentation error in a fifty-line file, understanding docker compose syntax thoroughly will save you time and prevent avoidable outages.

Docker Compose files are YAML, which means whitespace matters and small mistakes compound quickly. This article assumes you already have Docker installed and are comfortable running basic containers, and focuses specifically on the syntax rules and structural conventions that make Compose files predictable and easy to review.

Why Docker Compose Syntax Matters

A Compose file isn’t just configuration — it’s the contract between your application’s services. Get the docker compose syntax wrong and you might end up with a service that silently fails to mount a volume, a network alias that never resolves, or an environment variable that’s interpreted as a boolean instead of a string. YAML’s strictness about indentation (spaces only, never tabs) is one of the most common sources of these failures, especially for engineers coming from JSON or INI-based config formats.

Because Compose files are usually checked into version control and reviewed by teammates, consistent syntax also matters for readability. A file with mixed indentation styles or inconsistent key ordering is harder to diff and harder to trust during a code review.

The Basic File Structure

Every Compose file has a small number of top-level keys. The most important is services, which defines the containers that make up your application. Optional top-level keys include volumes, networks, and configs/secrets for resources shared across services.

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    depends_on:
      - api

  api:
    build: ./api
    environment:
      - NODE_ENV=production
    volumes:
      - api_data:/data

volumes:
  api_data:

Note that older Compose files often included a version: key at the top (e.g. version: "3.8"). The Compose Specification, which is what modern Docker Compose (the docker compose CLI plugin, as opposed to the legacy standalone docker-compose binary) implements, no longer requires this field, and Docker’s own documentation now recommends omitting it.

Indentation and Key Ordering Rules

YAML uses indentation to represent nesting, and Compose is no exception. A few rules to internalize:

  • Use two spaces per indentation level; never mix tabs and spaces.
  • Lists (like ports or volumes entries) are denoted with a leading dash and a single space.
  • Mapping keys under a service (like image, build, environment) must all be indented at the same level directly under the service name.
  • Quoting is optional in most cases, but strings that look like numbers, booleans, or contain a colon (such as port mappings) should be quoted to avoid YAML misinterpreting them.
  • A very common docker compose syntax mistake is writing ports: 8080:80 instead of a proper list item. Compose expects a sequence here, so the correct form is always a dash-prefixed entry under the ports key.

    Defining Services: Core Directives

    The services block is where most of your Compose file lives. Each service maps to a running container, and Docker Compose handles building images, creating networks, and starting containers in dependency order.

    Image vs. Build

    You’ll typically choose one of two ways to source a container image for a service:

    services:
      cache:
        image: redis:7-alpine
    
      app:
        build:
          context: .
          dockerfile: Dockerfile.prod
          args:
            NODE_VERSION: "20"

    image pulls a pre-built image from a registry. build tells Compose to construct the image locally from a Dockerfile, and you can pass build arguments through the args key. If you’re unsure when to reach for one over the other, the Dockerfile vs Docker Compose comparison covers the distinction between building images and orchestrating containers in more depth, and the related Docker Compose vs Dockerfile guide walks through concrete examples of each.

    Ports, Volumes, and Networks

    These three directives handle how your service is exposed and how data persists:

    services:
      db:
        image: postgres:16
        ports:
          - "5432:5432"
        volumes:
          - db_data:/var/lib/postgresql/data
        networks:
          - backend
    
    volumes:
      db_data:
    
    networks:
      backend:

    Port mappings follow the HOST:CONTAINER format. Volumes can be named (as above, backed by a Docker-managed volume) or bind-mounted to a host path using an absolute or relative path string like ./config:/etc/app/config. For a deeper walkthrough of volume-specific syntax and common pitfalls, see the Docker Compose Volumes guide.

    Environment Variables

    Environment variables can be declared inline as a list, as a mapping, or pulled from an external file:

    services:
      api:
        image: myapp/api:latest
        environment:
          DATABASE_URL: postgres://user:pass@db:5432/appdb
          LOG_LEVEL: info
        env_file:
          - .env

    Both the list form (- KEY=value) and the mapping form (KEY: value) are valid docker compose syntax for the environment key — pick one convention and use it consistently across your files. The Docker Compose Env guide and the more detailed Docker Compose Environment Variables reference both go further into variable substitution, default values, and precedence rules when the same variable is set in multiple places.

    Multi-Document and Override Files

    A single docker-compose.yml is fine for small projects, but most real deployments split configuration across a base file and one or more overrides — for example, docker-compose.yml plus docker-compose.override.yml for local development, or a separate docker-compose.prod.yml for production.

    docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

    Compose merges these files in the order given on the command line, with later files overriding matching keys from earlier ones. Understanding merge behavior is part of understanding docker compose syntax as a whole, because a misplaced key in an override file can silently shadow a setting you expect to be active.

    Common Merge Pitfalls

    A few things trip people up when working with multiple Compose files:

  • Lists (like ports or command) are replaced entirely by an override, not merged item-by-item.
  • Mappings (like environment when written as key-value pairs) are merged key-by-key.
  • depends_on entries can be extended, but the format must match between files (short list form vs. long form with condition).
  • If a merged stack behaves unexpectedly, check the output of docker compose config, which prints the fully resolved configuration after all files and variable substitutions are applied — often the fastest way to spot a syntax or merge issue.

    Extension Fields and YAML Anchors

    For larger files with repeated blocks, YAML anchors and the Compose Specification’s extension fields (keys prefixed with x-) help avoid duplication:

    x-common-env: &common-env
      TZ: UTC
      LOG_LEVEL: info
    
    services:
      worker:
        image: myapp/worker:latest
        environment:
          <<: *common-env
          QUEUE_NAME: default
    
      scheduler:
        image: myapp/scheduler:latest
        environment:
          <<: *common-env
          QUEUE_NAME: scheduled

    This isn’t Compose-specific — it’s standard YAML — but it’s a useful pattern once your service definitions start repeating the same environment variables, labels, or logging configuration across multiple services.

    Dependency Ordering and Health Checks

    depends_on controls startup order but, by default, only waits for the dependency’s container to start, not for the application inside it to be ready. For services like databases where “started” and “ready to accept connections” are different moments, pair depends_on with a healthcheck:

    services:
      db:
        image: postgres:16
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 5s
          timeout: 5s
          retries: 5
    
      api:
        build: .
        depends_on:
          db:
            condition: service_healthy

    This long-form depends_on syntax, using condition: service_healthy, tells Compose to wait until the healthcheck passes before starting the dependent service. This is one of the more important pieces of docker compose syntax to get right in any stack involving a database, since a race between an application container and its database is a frequent source of intermittent startup failures. If you’re specifically working with Postgres, the Postgres Docker Compose setup guide and the related PostgreSQL Docker Compose guide walk through healthcheck configuration in more detail, and the same pattern applies well to Redis Docker Compose setups.

    Logging and Debugging Compose Files

    Once your file is syntactically valid, the next challenge is often understanding what’s happening at runtime. docker compose config validates and prints the resolved file, which is the first thing to run when a service isn’t behaving as the raw YAML suggests. For runtime issues, docker compose logs -f <service> streams output from a running container.

    Validating Before You Deploy

    Running docker compose config --quiet in a CI pipeline is a cheap way to catch docker compose syntax errors before they reach a server. It exits non-zero if the file fails to parse or references an undefined variable without a default, which makes it easy to wire into a pre-deploy check.

    docker compose config --quiet && echo "Compose file is valid"

    For deeper debugging once containers are running, the Docker Compose Logs guide and the related Docker Compose Logging reference cover log drivers, tailing multiple services at once, and filtering output — useful once you’ve confirmed the syntax itself isn’t the problem.

    Secrets and Sensitive Configuration

    Avoid putting credentials directly in environment blocks committed to version control. Compose supports a dedicated secrets top-level key, which is especially relevant when running in Swarm mode but is also usable to reference files that Compose mounts into containers at runtime:

    services:
      api:
        image: myapp/api:latest
        secrets:
          - db_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt

    This keeps sensitive values out of the environment block and out of docker inspect output in cases where that distinction matters for your security posture. The Docker Compose Secrets guide covers this pattern in more depth, including how it differs from plain environment variables and bind-mounted config files.

    FAQ

    Does docker compose syntax require a version key at the top of the file?
    No. Modern Docker Compose implements the Compose Specification, which does not require a version field. If present, it’s largely ignored by current versions of the CLI. New files can omit it entirely.

    What’s the difference between the short and long syntax for depends_on?
    The short form is a plain list of service names (depends_on: [db, cache]) and only waits for the dependency container to start. The long form uses a mapping with a condition key (e.g. condition: service_healthy) and can wait for a healthcheck to pass, which is generally the more reliable choice for databases and other stateful dependencies.

    Can I use environment variables inside a Compose file itself, not just inside containers?
    Yes. Compose supports variable substitution using ${VARIABLE_NAME} syntax, sourced from the shell environment or a .env file in the same directory as the Compose file. You can also provide defaults inline, like ${PORT:-3000}.

    Why does my Compose file fail with an indentation error even though it looks correctly formatted in my editor?
    This is almost always a tabs-vs-spaces issue, since YAML forbids tabs for indentation. Many editors render tabs and spaces identically, so the mismatch is invisible until a YAML parser rejects it. Configuring your editor to insert spaces for the Compose file type (and to visualize whitespace) usually resolves it.

    Conclusion

    Docker Compose syntax is straightforward once you internalize a handful of rules: consistent two-space indentation, correct use of lists versus mappings, and an understanding of how multiple files merge together. Most real-world Compose problems trace back to one of a small set of causes — a tab where a space was expected, an unquoted string that YAML parses differently than intended, or a depends_on that doesn’t actually wait for readiness. Running docker compose config early and often, keeping secrets out of plain environment blocks, and splitting configuration across base and override files as your stack grows will keep even fairly large multi-service applications maintainable. For the authoritative and continuously updated reference on every available key, the Docker Compose file reference on docs.docker.com and the underlying Compose Specification on GitHub are worth bookmarking alongside this guide.

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *