Repository: docker/compose

Issue:
[BUG] Expose Range `Error response from daemon: invalid JSON: invalid port '<start>-<end>': invalid syntax`
### Description

Expose of port range results in invalid syntax.  My first experience of this is with:
`Docker version 29.0.0, build 3d4129b`

### Steps To Reproduce

1. Docker version 29.0.0, build 3d4129b on Ubuntu 24.04.3 LTS

2. 
```
services:
  test:
    image: alpine
    expose:
      - "9091-9092"
```

3.
```
docker compose -f ./text.xml run test
```

4.
```
Error response from daemon: invalid JSON: invalid port '9091-9092: invalid syntax
```

### Compose Version

```Text
Docker Compose version v2.40.3
```

### Docker Environment

```Text
Client: Docker Engine - Community
 Version:    29.0.0
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.30.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.40.3
    Path:     /usr/libexec/docker/cli-plugins/docker-compose
WARNING: Plugin "/usr/libexec/docker/cli-plugins/docker-compose_2-12-2" is not valid: plugin candidate "compose_2-12-2" did not match "^[a-z][a-z0-9]*$"

Server:
 Containers: 21
  Running: 21
  Paused: 0
  Stopped: 0
 Images: 20
 Server Version: 29.0.0
 Storage Driver: zfs
  Zpool: rpool
  Zpool Health: ONLINE
  Parent Dataset: rpool/ROOT/ubuntu_549uxf/var/lib
  Space Used By Parent: 11773657088
  Space Available: 138369679360
  Parent Quota: no
  Compression: lz4
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 CDI spec directories:
  /etc/cdi
  /var/run/cdi
 Swarm: inactive
 Runtimes: runc io.containerd.runc.v2
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: fcd43222d6b07379a4be9786bda52438f0dd16a1
 runc version: v1.3.3-0-gd842d771
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.8.0-87-generic
 Operating System: Ubuntu 24.04.3 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 8
 Total Memory: 7.644GiB
 Name: tmk-svr-01
 ID: OVLQ:BHCM:EYES:2XNJ:IBFZ:ANTP:GEPH:Y5M7:FDET:EB5P:NOQB:G6VX
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  ::1/128
  127.0.0.0/8
 Live Restore Enabled: true
 Firewall Backend: iptables
```

### Anything else?

_No response_

Hints from issue comments:
@idsulik: Couldn't reproduce it, tried with this config:
```
services:
  test:
    image: alpine
    expose:
      - "9091-9092"
```@ndeloof: I can reproduce running docker:dind 29.0.1
looks like there is a breaking change in Docker engine@tmknight: Yes, now on 29.0.1 & still expose range returns invalid.  Thanks @ndeloof for confirming the behavior.@ndeloof: I wonder this could be a side effect of https://github.com/moby/moby/pull/50710@felixfontein: Here's a related issue on moby/moby's side: https://github.com/moby/moby/issues/51537@Neurone: I have the same error with v4.53.0 on MacOS

```shell
~ docker version

Client:
 Version:           29.0.1
 API version:       1.52
 Go version:        go1.25.4
 Git commit:        eedd969
 Built:             Fri Nov 14 16:16:57 2025
 OS/Arch:           darwin/arm64
 Context:           desktop-linux

Server: Docker Desktop 4.53.0 (211793)
 Engine:
  Version:          29.0.1
  API version:      1.52 (minimum version 1.44)
  Go version:       go1.25.4
  Git commit:       198b5e3
  Built:            Fri Nov 14 16:18:20 2025
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          v2.1.5
  GitCommit:        fcd43222d6b07379a4be9786bda52438f0dd16a1
 runc:
  Version:          1.3.3
  GitCommit:        v1.3.3-0-gd842d771
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0


~ docker compose version
Docker Compose version v2.40.3-desktop.1
```@ndeloof: @Neurone please git RC release a try to confirm issue has been fixed@Neurone: Hi @ndeloof the latest RC (https://github.com/docker/compose/releases/tag/v5.0.0-rc.2) is one week older than the fix.
I tested anyway, just in case, but I get the same error.@ndeloof: oh indeed, sorry.
v5.0.0 release is on its way with the fix included@Neurone: I just tested v5 and everything works again as expected. Many thanks!

Solution patch:
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index 59dc5687..dc573fb5 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -210,11 +210,17 @@ func (s *composeService) getCreateConfigs(ctx context.Context,
 	if err != nil {
 		return createConfigs{}, err
 	}
+
+	exposed, err := buildContainerPorts(service)
+	if err != nil {
+		return createConfigs{}, err
+	}
+
 	containerConfig := container.Config{
 		Hostname:        service.Hostname,
 		Domainname:      service.DomainName,
 		User:            service.User,
-		ExposedPorts:    buildContainerPorts(service),
+		ExposedPorts:    exposed,
 		Tty:             tty,
 		OpenStdin:       stdinOpen,
 		StdinOnce:       opts.AttachStdin && stdinOpen,
@@ -763,17 +769,24 @@ func setBlkio(blkio *types.BlkioConfig, resources *container.Resources) {
 	}
 }
 
-func buildContainerPorts(s types.ServiceConfig) nat.PortSet {
+func buildContainerPorts(s types.ServiceConfig) (nat.PortSet, error) {
 	ports := nat.PortSet{}
 	for _, s := range s.Expose {
-		p := nat.Port(s)
-		ports[p] = struct{}{}
+		proto, port := nat.SplitProtoPort(s)
+		start, end, err := nat.ParsePortRange(port)
+		if err != nil {
+			return nil, err
+		}
+		for i := start; i <= end; i++ {
+			p := nat.Port(fmt.Sprintf("%d/%s", i, proto))
+			ports[p] = struct{}{}
+		}
 	}
 	for _, p := range s.Ports {
 		p := nat.Port(fmt.Sprintf("%d/%s", p.Target, p.Protocol))
 		ports[p] = struct{}{}
 	}
-	return ports
+	return ports, nil
 }
 
 func buildContainerPortBindingOptions(s types.ServiceConfig) nat.PortMap {



Test patch:
diff --git a/pkg/e2e/expose_test.go b/pkg/e2e/expose_test.go
new file mode 100644
index 00000000..438f7b54
--- /dev/null
+++ b/pkg/e2e/expose_test.go
@@ -0,0 +1,48 @@
+//go:build !windows
+
+/*
+   Copyright 2020 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package e2e
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+// see https://github.com/docker/compose/issues/13378
+func TestExposeRange(t *testing.T) {
+	c := NewParallelCLI(t)
+
+	f := filepath.Join(t.TempDir(), "compose.yaml")
+	err := os.WriteFile(f, []byte(`
+name: test-expose-range
+services:
+  test:
+    image: alpine
+    expose:
+      - "9091-9092"
+`), 0o644)
+	assert.NilError(t, err)
+
+	t.Cleanup(func() {
+		c.cleanupWithDown(t, "test-expose-range")
+	})
+	c.RunDockerComposeCmd(t, "-f", f, "up")
+}