Repository: docker/compose
Pull request #12722 (2025-04-08 17:46:20)

mount API is not strictly equivalent to bind
**What I did**
A volume mount with an actual bind (driver option `o: bind`)  must be configured using `bind` API otherwise we get some weird behavior

**Related issue**
https://docker.atlassian.net/browse/CSESC-577

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**


thaJeztah: @ndeloof I think this was the comment where the original idea of “allow a named volume with a custom host path” (so, largely a “bind-mount”) comes from; https://github.com/moby/moby/issues/19990#issuecomment-248955005 

Note that the comment was really a "hack"; it depends on knowing the right options for the mount syscall, and it's tricking the docker engine into thinking it deals with a volume, not a bind-mount (to do the "copy files to the target destination").


One thing to be aware of is that in that (technically) “o=bind” could have multiple options; also (as mentioned) that it DOES act as a volume, because the docker engine is not aware of the mount syscall options; for example;

```bash
docker volume create --opt type=none --opt device=/root/mount-target --opt o=bind bindvolume
```

This is expected: docker is not aware of any host-path dependency that the mount syscall options require;

```bash
docker run --rm -v bindvolume:/usr/share/nginx/ nginx:alpine sh -c 'echo hello > /usr/share/nginx/html/foo.txt'
# docker: Error response from daemon: failed to mount local volume: mount /root/mount-target:/var/lib/docker/volumes/bindvolume/_data, flags: 0x1000: no such file or directory.
```


Creating the path first makes it work, but also be aware that the Docker Engine considers it a volume, so (unlike a bind-mount) will copy files to the given location if the target path on the host is empty;

```bash
mkdir -p /root/mount-target
docker run --rm -v bindvolume:/usr/share/nginx/ nginx:alpine sh -c 'echo hello > /usr/share/nginx/html/foo.txt'

tree /root/mount-target
/root/mount-target
└── html
    ├── 50x.html
    ├── foo.txt
    └── index.html

1 directory, 3 files
```
apollo13: Hi, just stumbled over this by accident. We are using volumes like @thaJeztah showed to have docker volumes with the copy behavior on a custom host path. Another upside is that deleting the volume does not delete the underyling data :)

I don't fully understand what this MR changes, but it would be great if this "hack" would continue to work (or at least offers a different way to achieve the same).
apollo13: > In general, named volumes were designed for that specifically; if a volume has a name, docker will avoid deleting it (unless you ask it to), e.g. docker volume prune won't remove named volumes by default. 

Yes, but adding a label to a volume is not something docker compose can do without destroying the volume which is unfortunate.

> The downside of using this hack (at least on Docker Desktop) is that the files are copied out of the VM onto the host, and mounted back.

Yes, but using a VM can already be considered a hack in itself :D

Patch:
diff --git a/go.mod b/go.mod
index 243503f7a..0c0044dce 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/Microsoft/go-winio v0.6.2
 	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
 	github.com/buger/goterm v1.0.4
-	github.com/compose-spec/compose-go/v2 v2.5.1-0.20250409070949-8e1a035095ca
+	github.com/compose-spec/compose-go/v2 v2.6.0
 	github.com/containerd/containerd/v2 v2.0.4
 	github.com/containerd/platforms v1.0.0-rc.1
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
diff --git a/go.sum b/go.sum
index 2f92e5d03..95d65c6ab 100644
--- a/go.sum
+++ b/go.sum
@@ -83,8 +83,8 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e
 github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
-github.com/compose-spec/compose-go/v2 v2.5.1-0.20250409070949-8e1a035095ca h1:4dH4DudeDunWTYetcJxQE65osreQKvaLtFLdl9CcqME=
-github.com/compose-spec/compose-go/v2 v2.5.1-0.20250409070949-8e1a035095ca/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA=
+github.com/compose-spec/compose-go/v2 v2.6.0 h1:/+oBD2ixSENOeN/TlJqWZmUak0xM8A7J08w/z661Wd4=
+github.com/compose-spec/compose-go/v2 v2.6.0/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA=
 github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
 github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
diff --git a/pkg/compose/build.go b/pkg/compose/build.go
index 1a2c3f94d..a32fe6528 100644
--- a/pkg/compose/build.go
+++ b/pkg/compose/build.go
@@ -320,8 +320,7 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
 func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) {
 	imageNames := utils.Set[string]{}
 	for _, s := range project.Services {
-		imgName := api.GetImageNameOrDefault(s, project.Name)
-		imageNames.Add(imgName)
+		imageNames.Add(api.GetImageNameOrDefault(s, project.Name))
 		for _, volume := range s.Volumes {
 			if volume.Type == types.VolumeTypeImage {
 				imageNames.Add(volume.Source)
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index 8e7729b38..999b50f43 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -828,6 +828,7 @@ func getDependentServiceFromMode(mode string) string {
 	return ""
 }
 
+//nolint:gocyclo
 func (s *composeService) buildContainerVolumes(
 	ctx context.Context,
 	p types.Project,
@@ -848,30 +849,37 @@ func (s *composeService) buildContainerVolumes(
 		return nil, nil, err
 	}
 
-MOUNTS:
 	for _, m := range mountOptions {
-		if m.Type == mount.TypeNamedPipe {
-			mounts = append(mounts, m)
-			continue
-		}
-		if m.Type == mount.TypeBind {
+		switch m.Type {
+		case mount.TypeBind:
 			// `Mount` is preferred but does not offer option to created host path if missing
 			// so `Bind` API is used here with raw volume string
 			// see https://github.com/moby/moby/issues/43483
-			for _, v := range service.Volumes {
-				if v.Target == m.Target {
-					switch {
-					case string(m.Type) != v.Type:
-						v.Source = m.Source
-						fallthrough
-					case !requireMountAPI(v.Bind):
-						binds = append(binds, v.String())
-						continue MOUNTS
+			v := findVolumeByTarget(service.Volumes, m.Target)
+			if v != nil {
+				switch {
+				case v.Type != types.VolumeTypeBind:
+					v.Source = m.Source
+					fallthrough
+				case !requireMountAPI(v.Bind):
+					vol := findVolumeByName(p.Volumes, m.Source)
+					if vol != nil {
+						binds = append(binds, toBindString(vol.Name, v))
+						continue
 					}
 				}
 			}
-		}
-		if m.Type == mount.TypeImage {
+		case mount.TypeVolume:
+			v := findVolumeByTarget(service.Volumes, m.Target)
+			vol := findVolumeByName(p.Volumes, m.Source)
+			if v != nil && vol != nil {
+				if _, ok := vol.DriverOpts["device"]; ok && vol.Driver == "local" && vol.DriverOpts["o"] == "bind" {
+					// Looks like a volume, but actually a bind mount which requires the bind API
+					binds = append(binds, toBindString(vol.Name, v))
+					continue
+				}
+			}
+		case mount.TypeImage:
 			version, err := s.RuntimeVersion(ctx)
 			if err != nil {
 				return nil, nil, err
@@ -885,6 +893,42 @@ MOUNTS:
 	return binds, mounts, nil
 }
 
+func toBindString(name string, v *types.ServiceVolumeConfig) string {
+	access := "rw"
+	if v.ReadOnly {
+		access = "ro"
+	}
+	options := []string{access}
+	if v.Bind != nil && v.Bind.SELinux != "" {
+		options = append(options, v.Bind.SELinux)
+	}
+	if v.Bind != nil && v.Bind.Propagation != "" {
+		options = append(options, v.Bind.Propagation)
+	}
+	if v.Volume != nil && v.Volume.NoCopy {
+		options = append(options, "nocopy")
+	}
+	return fmt.Sprintf("%s:%s:%s", name, v.Target, strings.Join(options, ","))
+}
+
+func findVolumeByName(volumes types.Volumes, name string) *types.VolumeConfig {
+	for _, vol := range volumes {
+		if vol.Name == name {
+			return &vol
+		}
+	}
+	return nil
+}
+
+func findVolumeByTarget(volumes []types.ServiceVolumeConfig, target string) *types.ServiceVolumeConfig {
+	for _, v := range volumes {
+		if v.Target == target {
+			return &v
+		}
+	}
+	return nil
+}
+
 // requireMountAPI check if Bind declaration can be implemented by the plain old Bind API or uses any of the advanced
 // options which require use of Mount API
 func requireMountAPI(bind *types.ServiceVolumeBind) bool {
diff --git a/pkg/compose/down.go b/pkg/compose/down.go
index 00580a50d..07b7e5840 100644
--- a/pkg/compose/down.go
+++ b/pkg/compose/down.go
@@ -282,8 +282,15 @@ func (s *composeService) removeImage(ctx context.Context, image string, w progre
 
 func (s *composeService) removeVolume(ctx context.Context, id string, w progress.Writer) error {
 	resource := fmt.Sprintf("Volume %s", id)
+
+	_, err := s.apiClient().VolumeInspect(ctx, id)
+	if errdefs.IsNotFound(err) {
+		// Already gone
+		return nil
+	}
+
 	w.Event(progress.NewEvent(resource, progress.Working, "Removing"))
-	err := s.apiClient().VolumeRemove(ctx, id, true)
+	err = s.apiClient().VolumeRemove(ctx, id, true)
 	if err == nil {
 		w.Event(progress.NewEvent(resource, progress.Done, "Removed"))
 		return nil


Test patch:
diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go
index ac147d653..faeb0a81d 100644
--- a/pkg/compose/down_test.go
+++ b/pkg/compose/down_test.go
@@ -254,6 +254,8 @@ func TestDownRemoveVolumes(t *testing.T) {
 		Return(volume.ListResponse{
 			Volumes: []*volume.Volume{{Name: "myProject_volume"}},
 		}, nil)
+	api.EXPECT().VolumeInspect(gomock.Any(), "myProject_volume").
+		Return(volume.Volume{}, nil)
 	api.EXPECT().NetworkList(gomock.Any(), network.ListOptions{Filters: filters.NewArgs(projectFilter(strings.ToLower(testProject)))}).
 		Return(nil, nil)