Mert Özen Further With Every Line
DevOps Docker Compose — #07

The Evolution of Compose: From docker-compose v1 to Compose v2 and v5, the Compose Specification and 2024–2026 Features

Mert Özen Nov 14, 2026 9 min 14 views
The Evolution of Compose: From docker-compose v1 to Compose v2 and v5, the Compose Specification and 2024–2026 Features

Today's Compose implements a single schema, the Compose Specification: the version field in old files is ignored, fields from the 2.x and 3.x formats are valid together, and v5 continues v2 with a Go SDK and builds handed to Bake. We run Bookstore's 2019 docker-compose.yml with docker-compose 1.25.5, 1.29.2 and Compose 5.7.1, look at the differences with real output, and map the 2024–2026 features and a migration checklist.

Version note: The examples in this post were run with Docker Compose 5.7.1, docker-compose 1.29.2 and 1.25.5 (the official docker/compose images on Docker Hub), Docker Engine 29.9.3 and the SQL Server 2019/2025 images.

Guess First

The first version of Bookstore started with a docker-compose.yml written in 2019: version: "3.8" at the top, deploy.resources.limits.memory: 512M on the API, links and a short depends_on for dependencies. Did the docker-compose of that time apply this memory limit to the container? Does today's Compose 5.7.1 run the same file, and if so, is the limit applied? And: what happens if you put the docker-compose binary of that day next to today's Docker Engine 29?

A Short History: Tool Versions and File Formats

Two separate numbering schemes get mixed up in Compose's history: the version of the tool and the version of the file format. A summary compiled from Docker's Compose history page and the release notes:

PeriodToolFileNote
2014Compose v1 (Python), docker-composeFormat 1 (no top-level services)Today's Compose does not run these files
2016v1 1.6Format 2.xSingle machine; depends_on conditions and mem_limit are here
2017v1 1.10Format 3.xSwarm-oriented; deploy arrived, conditions were dropped
2020–2022Compose v2 (Go), docker composeCompose Specification2.x and 3.x merged into one schema; v2 GA in April 2022
June 2023v1Support for v1 ended
December 2025Compose v5Compose SpecificationFunctionally the same as v2; official Go SDK, builds in Bake

The split between the 2.x and 3.x formats caused confusion for years. Format 2.x was for applications running on a single machine: it had container settings such as condition: service_healthy in depends_on and mem_limit. Format 3.x was designed to take the same file to Docker Swarm (docker stack deploy): resource and replica settings moved under deploy, and the depends_on conditions, meaningless in Swarm, were removed. The result: teams running on a single machine moved to 3.x because it was "newer", lost their health check conditions and memory limits, and wait-for-it scripts entered the READMEs. The Compose Specification ended this split: today there is one schema, and the fields from both formats are valid.

Reading an Old File

We put Bookstore's hypothetical 2019 file into labs/compose/07-legacy/. In post 15 of the Docker series we read the Dockerfile of the same period; this is its Compose side:

# Bookstore as it was started in 2019: Compose file format 3.8, run with docker-compose (v1)
version: "3.8"

services:
  db:
    image: mcr.microsoft.com/mssql/server:2019-latest
    container_name: composeseries-legacy-db
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=${SA_PASSWORD}
    ports:
      - "1433:1433"
    volumes:
      - dbdata:/var/opt/mssql

  api:
    image: bookstore/api:2019
    links:
      - db
    depends_on:
      - db
    environment:
      - ASPNETCORE_URLS=http://+:80
      - ConnectionStrings__Default=Server=db;Database=BookstoreDb;User Id=sa;Password=${SA_PASSWORD}
    ports:
      - "5000:80"
    deploy:
      resources:
        limits:
          memory: 512M

  web:
    image: bookstore/web:2019
    depends_on:
      - api
    ports:
      - "80:8080"

volumes:
  dbdata:

Every line of the file carries a habit of its time: version, environment as a list, container_name, links, port 80, an application connecting as sa, the password inside the connection string. We come back to all of them in the checklist; first let us see what the file does in three different Composes.

v1 and the 2.x/3.x Difference: With Real Output

Docker's official docker/compose images on Docker Hub let you run old versions in a container; we mounted the Docker socket and tried 1.25.5, one of the last v1 releases:

V1="docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v $PWD:$PWD -w $PWD -e SA_PASSWORD docker/compose:1.25.5"
$V1 -p composeseries-legacy up --no-start
Some services (api) use the 'deploy' key, which will be ignored. Compose does not support 'deploy' configuration - use `docker stack deploy` to deploy to a swarm.
client version 1.38 is too old. Minimum supported API version is 1.40, please upgrade your client to a newer version

Two messages, two separate history lessons. The first is the answer to the first question: 1.25.5 explicitly ignores deploy in a 3.x file. A team believing "the API is limited to 512 MB" on a production server was actually running an unlimited container. The second is the answer to the third question: 1.25.5 speaks version 1.38 of the Docker API, while Engine 29 requires at least 1.40; the old binary cannot start anything on today's server. Post 15 of the Docker series covers the Engine's minimum API version in detail.

When we wrote depends_on in the same file with a health check condition, the 3.8 schema rejected it; the same condition passed config without a problem in version: "2.4" (and with mem_limit: 512m instead of deploy):

The Compose file './cond.yml' is invalid because:
services.api.depends_on contains an invalid type, it should be an array

The last v1 release, 1.29.2, behaved differently: with the same 3.8 file the containers were created and the API's memory limit was 536870912 bytes, that is 512 MB. From 1.27 on, v1 had started implementing the Compose Specification; the config output had no version field and the short depends_on was expanded to the long form (condition: service_started). So "deploy does not work on a single machine in 3.x" is true or false depending on which v1 release you used.

The Same File Today: Compose 5.7.1

docker compose -p composeseries-legacy up --no-start
docker inspect composeseries-legacy-api-1 --format "memory={{.HostConfig.Memory}}"
WARN[0000] /home/user/bookstore/labs/compose/07-legacy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion
 Network composeseries-legacy_default Created
 Container composeseries-legacy-db Created
 Container composeseries-legacy-api-1 Created
 Container composeseries-legacy-web-1 Created
memory=536870912

The file runs; the only warning is about version. The limit is applied. One difference is in the container names: 1.29.2 had created composeseries-legacy_api_1, Compose v2 and later create composeseries-legacy-api-1. The separator changed from underscore to hyphen; according to Docker's migration documentation the goal is to make the names suitable for use as hostnames, since the underscore is not a valid character in hostnames. Old monitoring scripts that grep for container names silently broke at the move to v2 for this reason. The db with a container_name got the same name in both versions: a fixed name prevents a second copy of the project from running on the same machine and prevents the service from being scaled.

v5: Why There Is No 3 or 4, the SDK and Bake

The release after Compose v2's 2.40 came out as 5.0.0 in December 2025. According to the release notes, 3.0.0 was skipped on purpose, because the name "Compose v3" would have been confused with the old 2.x and 3.x file formats; 4.0.0 was skipped as well, to separate clearly from that legacy. v5 is functionally the continuation of v2; there are two concrete changes. First, Compose can now also be used as an official Go SDK: another application can load, validate and manage Compose projects without calling the CLI. Second, the BuildKit builder inside Compose was removed and builds were handed over to Bake. That move was gradual: in 2.37 (June 2025) Bake became the default and could be turned off with COMPOSE_BAKE=false, in 2.39 that setting was deprecated, in 5.0 Bake is the only way. That is why all build output in this series looks like docker buildx bake output.

The 2024–2026 Feature Map

Compose did not just change its version number over the last two years. The ones that matter for Bookstore, with the version markers in the Compose Specification and the GitHub release notes:

FeatureVersionIn Bookstore
profilesv1 1.28 (2021)Redis, Mailpit, sqlcmd (post 3)
include2.20compose.db.yaml (post 5)
develop.watch, initial_sync2.22, 2.39.4API rebuild, Angular sync (post 4)
!reset, !override2.24Resetting ports and build (posts 5 and 6)
provider services2.36Not used
models2.38Not used
Reconciliation plan5.2In the background; visible with --dry-run
pre_start (init container)5.3Alternative for the migration (post 2)
jobs (manually triggered only)5.6Backup job (post 6)

Why did we not use the others? provider lets you represent a service, instead of a container, by a resource managed by a binary that Compose calls from outside; all of Bookstore's parts are containers anyway. models connects an LLM to services through Docker Model Runner and passes its endpoint as an environment variable; Bookstore has no feature that uses an LLM. The reconciliation plan is a change you do not see but feel: since Compose 5.2, up compares the current state with the desired one and builds a plan, and in 5.7 the create and start steps run on a single plan. To see the plan without applying it, use --dry-run:

TAG=1.3.1 docker compose up -d --dry-run
 Container composeseries-prod-db-1 Running
 Container composeseries-prod-api-1 Recreate
 Container composeseries-prod-api-1 Recreated
 Container composeseries-prod-web-1 Recreate
 Container composeseries-prod-web-1 Recreated
 Container composeseries-prod-migrate-1 Starting
 ...
 Container composeseries-prod-web-1 Started 

The cause of the 11.5-second outage in the previous post (web being recreated too and migrate running again) was visible in this output before the deploy; after the command, ps still showed 1.3.0. The image digest reconciliation change in 5.5 is worth noting as well: the release notes say some containers may be recreated once on the first up after the upgrade; in production, schedule upgrading Compose into a deploy window.

Checklist: From docker-compose.yml to compose.yaml

  • Rename the file to compose.yaml, delete the version line, add name: if needed; switch commands from docker-compose to docker compose and fix scripts that grep for container names to use the hyphen separator.
  • Remove links (service names on the same network resolve anyway), keep container_name only if a fixed name is really needed.
  • Turn the short depends_on into the long form with health checks, delete wait-for-it scripts, move the schema step into a migrate service or pre_start (post 2).
  • Change SA_PASSWORD (deprecated on Microsoft Learn) to MSSQL_SA_PASSWORD, preferably a secret file; connect the application with a login with narrowed permissions instead of sa, and move passwords from the environment to secrets: (post 3).
  • From .NET 8 on, the container port is 8080, not 80: update the ASPNETCORE_URLS=http://+:80 and "5000:80" lines (post 15 of the Docker series).
  • Pick one of mem_limit and deploy.resources.limits; if both are written, they must be consistent. Then verify with docker inspect that the limit is really applied.
  • Split development additions into compose.override.yaml and environment differences into -f files; review the resolved result with docker compose config (post 5). In production, pull images by immutable tag; to pin them to digests, docker compose config --lock-image-digests produces an override file.

In Production

When you take over an old server, the first thing is to see which Compose really runs there: docker compose version and docker-compose version may be different binaries, and a docker-compose installed from Ubuntu's own package repository may still turn out to be the Python v1. The points that change behavior during the move are deploy going from ignored (1.25 and earlier) to applied (the limits suddenly take effect and an application running above the limit can die with OOM), the container names changing and behaviors tied to version disappearing. On a project started by v1, the first up of v2 and later recreates the containers with their new names, so expect a short outage. Try the move first on the same server with a separate project name (-p) and make sure the volume names (<project>_<name>) stay the same as before; if the project name changes, Compose creates a new, empty volume and the database looks "deleted". Pin the Compose version on CI: GitHub-hosted runners and Docker Desktop come with different versions, and new fields such as pre_start or jobs cause a schema error on an old Compose.

Common Pitfalls

  • Thinking Compose applies the 3.8 rules because you wrote version: "3.8": the field is ignored, the Compose Specification applies.
  • Carrying "there are no depends_on conditions in 3.x" from an old blog post into today: the conditions came back in the Compose Specification.
  • Assuming a file that works in Docker Desktop also works on the server: Docker Desktop 4.99 ships Compose 5.7.0, the Compose on the server may be much older; pre_start needs 5.3, jobs 5.6.
  • Changing the project name and expecting the old volume to stay in use: the volume name is prefixed with the project name.
  • Assuming the hyphenated docker-compose command is v1 everywhere, or v5 everywhere: in Docker Desktop and the official images it is a name linked to v5, in an old Linux package it is v1.

A Small Experiment

★☆☆ Convert labs/compose/07-legacy/docker-compose.yml into a modern compose.yaml following the checklist and compare the docker compose config output for the two files. Which lines did not change at all in the output?

★★☆ Try running your converted file with docker run docker/compose:1.29.2. Which of the secrets, include or develop fields fails? From the error messages, work out which parts of the Compose Specification never made it into v1.

★★★ Produce a digest override for the production files with docker compose config --lock-image-digests and change the deploy script to also use that file with -f. Point the 1.3.0 tag in the registry at another image (the experiment from post 4 of the Docker series) and check which image the server runs.

We have reached the end of the Docker Compose series: Bookstore now comes up with one command in development, is tested on CI with a clean database and is deployed to a single server with immutable tags. Where one server is not enough is where the Kubernetes series begins; for those who want to bring up the same application from C# code, the upcoming Distributed Applications with Aspire series will cover Compose's AppHost counterpart.