Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AI_AGENT_DISCLOSURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
> *"This contribution was prepared by an AI agent acting on a human's behalf.
> The human submitter may not have independently reviewed or tested the change."*

2026-09-05
48 changes: 48 additions & 0 deletions docs/secrets-and-configs-driver-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# `secrets`/`configs`: `driver: copy`

## Problem

`secrets.<name>.file` and `configs.<name>.file` are, by default, interpreted as a path on the **Docker host**:
Compose creates a read-only bind mount from that host path into the container. This works fine when the
daemon is local, but breaks entirely once `DOCKER_HOST` or a Docker context points at a remote engine —
Compose is a client-side tool and cannot bind-mount a path that only exists on the client's machine into a
container running on a different host.

```yaml
services:
app:
secrets:
- db_password

secrets:
db_password:
file: ./db_password.txt # only works if the daemon is local
```

Against a remote `DOCKER_HOST`, the above fails (or silently mounts the wrong file, if a path with the same
name happens to exist on the remote host).

## `driver: copy`

Setting `driver: copy` on a secret or config opts it into being read from the **client's** local filesystem and
copied into the container, instead of bind-mounted from the Docker host:

```yaml
secrets:
db_password:
file: ./db_password.txt
driver: copy
```

This is an explicit **opt-in**, not the default, and this is deliberate: existing users may rely on the current
bind-mount behavior, e.g. to edit the file on the Docker host directly and see the change reflected in the
running container without a restart, or in combination with `docker compose watch`. Switching the default
would silently break those workflows, so nothing changes unless `driver: copy` is set. See
[docker/compose#11867](https://github.com/docker/compose/issues/11867) for the history of that decision.

Because the content is copied once, at container creation, editing the client's file afterwards has no effect
on already-running containers — recreate the service (`docker compose up`) to pick up a changed file, exactly
as for a secret declared with `content:`.

`driver: copy` requires `file` to be set. No other `driver` value is supported (as before, any other value is
rejected).
14 changes: 10 additions & 4 deletions pkg/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -1218,14 +1218,17 @@ func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount
return nil, fmt.Errorf("unsupported external config %s", definedConfig.Name)
}

if definedConfig.Driver != "" {
if definedConfig.Driver != "" && definedConfig.Driver != copyDriver {
return nil, errors.New("Docker Compose does not support configs.*.driver") //nolint:staticcheck
}
if definedConfig.TemplateDriver != "" {
return nil, errors.New("Docker Compose does not support configs.*.template_driver") //nolint:staticcheck
}
if definedConfig.Driver == copyDriver && definedConfig.File == "" {
return nil, fmt.Errorf("config %s: driver: copy requires file to be set", definedConfig.Name)
}

if definedConfig.Environment != "" || definedConfig.Content != "" {
if definedConfig.Environment != "" || definedConfig.Content != "" || definedConfig.Driver == copyDriver {
continue
}

Expand Down Expand Up @@ -1268,14 +1271,17 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount
return nil, fmt.Errorf("unsupported external secret %s", definedSecret.Name)
}

if definedSecret.Driver != "" {
if definedSecret.Driver != "" && definedSecret.Driver != copyDriver {
return nil, errors.New("Docker Compose does not support secrets.*.driver") //nolint:staticcheck
}
if definedSecret.TemplateDriver != "" {
return nil, errors.New("Docker Compose does not support secrets.*.template_driver") //nolint:staticcheck
}
if definedSecret.Driver == copyDriver && definedSecret.File == "" {
return nil, fmt.Errorf("secret %s: driver: copy requires file to be set", definedSecret.Name)
}

if definedSecret.Environment != "" {
if definedSecret.Environment != "" || definedSecret.Driver == copyDriver {
continue
}

Expand Down
62 changes: 62 additions & 0 deletions pkg/compose/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,68 @@ func TestBuildVolumeMount(t *testing.T) {
assert.Equal(t, mount.Type, mountTypes.TypeVolume)
}

// https://github.com/docker/compose/issues/11867
// driver: copy must skip the bind mount entirely: the secret/config is
// instead delivered by injectSecrets/injectConfigs (secrets.go), which
// copies the client-read content into the container after it's created.
func TestBuildContainerSecretMounts_CopyDriverSkipsBindMount(t *testing.T) {
project := composetypes.Project{
Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{
"db_password": {Name: "db_password", Driver: copyDriver, File: "/home/user/secret.txt"},
}),
}
service := composetypes.ServiceConfig{
Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}},
}

mounts, err := buildContainerSecretMounts(project, service)
assert.NilError(t, err)
assert.Equal(t, len(mounts), 0)
}

func TestBuildContainerSecretMounts_CopyDriverRequiresFile(t *testing.T) {
project := composetypes.Project{
Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{
"db_password": {Name: "db_password", Driver: copyDriver},
}),
}
service := composetypes.ServiceConfig{
Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}},
}

_, err := buildContainerSecretMounts(project, service)
assert.ErrorContains(t, err, "driver: copy requires file to be set")
}

func TestBuildContainerSecretMounts_UnsupportedDriverStillRejected(t *testing.T) {
project := composetypes.Project{
Secrets: composetypes.Secrets(map[string]composetypes.SecretConfig{
"db_password": {Name: "db_password", Driver: "vault"},
}),
}
service := composetypes.ServiceConfig{
Secrets: []composetypes.ServiceSecretConfig{{Source: "db_password"}},
}

_, err := buildContainerSecretMounts(project, service)
assert.ErrorContains(t, err, "does not support secrets.*.driver")
}

func TestBuildContainerConfigMounts_CopyDriverSkipsBindMount(t *testing.T) {
project := composetypes.Project{
Configs: composetypes.Configs(map[string]composetypes.ConfigObjConfig{
"app_config": {Name: "app_config", Driver: copyDriver, File: "/home/user/config.yaml"},
}),
}
service := composetypes.ServiceConfig{
Configs: []composetypes.ServiceConfigObjConfig{{Source: "app_config"}},
}

mounts, err := buildContainerConfigMounts(project, service)
assert.NilError(t, err)
assert.Equal(t, len(mounts), 0)
}

func TestServiceImageName(t *testing.T) {
assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Image: "myImage"}, "myProject"), "myImage")
assert.Equal(t, api.GetImageNameOrDefault(composetypes.ServiceConfig{Name: "aService"}, "myProject"), "myProject-aService")
Expand Down
24 changes: 24 additions & 0 deletions pkg/compose/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"bytes"
"context"
"fmt"
"os"
"strconv"
"time"

Expand All @@ -33,6 +34,22 @@ type mountType string
const (
secretMount mountType = "secret"
configMount mountType = "config"

// copyDriver is a Compose-specific `driver` value (secrets.*.driver /
// configs.*.driver) opting a `file`-based secret or config into being
// read from the CLIENT's local filesystem and copied into the
// container, instead of bind-mounted from a path on the Docker host.
// This is the only supported value; any other non-empty driver is
// still rejected, as before.
//
// It exists because `file:` bind-mounting a path off the Docker host
// breaks entirely when the daemon is remote (DOCKER_HOST/context):
// Compose is a client-side tool and cannot bind-mount a path that only
// exists on the client. Switching unconditionally was rejected upstream
// (https://github.com/docker/compose/issues/11867) as a breaking
// change for users who rely on the existing bind mount (live editing on
// the host, `watch`-driven sync); `driver: copy` makes it opt-in.
copyDriver = "copy"
)

func (s *composeService) injectSecrets(ctx context.Context, project *types.Project, service types.ServiceConfig, id string) error {
Expand Down Expand Up @@ -115,6 +132,13 @@ func (s *composeService) resolveFileContent(project *types.Project, source types
}
return env, nil
}
if source.Driver == copyDriver {
content, err := os.ReadFile(source.File)
if err != nil {
return "", fmt.Errorf("reading %s %q from client to copy into container: %w", mountType, source.Name, err)
}
return string(content), nil
}
return "", nil
}

Expand Down
80 changes: 80 additions & 0 deletions pkg/compose/secrets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
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 compose

import (
"os"
"path/filepath"
"testing"

"github.com/compose-spec/compose-go/v2/types"
"gotest.tools/v3/assert"
)

// https://github.com/docker/compose/issues/11867
// driver: copy must read a `file`-based secret or config from the CLIENT's
// local filesystem, so it can be delivered to a container on a remote
// Docker host that has no access to that path.
func TestResolveFileContent_CopyDriver(t *testing.T) {
s := &composeService{}
path := filepath.Join(t.TempDir(), "secret.txt")
assert.NilError(t, os.WriteFile(path, []byte("hunter2"), 0o600))

content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{
Name: "db_password",
Driver: copyDriver,
File: path,
}, secretMount)
assert.NilError(t, err)
assert.Equal(t, content, "hunter2")
}

func TestResolveFileContent_CopyDriverMissingFile(t *testing.T) {
s := &composeService{}
_, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{
Name: "db_password",
Driver: copyDriver,
File: filepath.Join(t.TempDir(), "missing.txt"),
}, secretMount)
assert.ErrorContains(t, err, "reading secret")
}

// Content and Environment still take priority over driver: copy, matching
// resolveFileContent's existing precedence.
func TestResolveFileContent_ContentTakesPriorityOverCopyDriver(t *testing.T) {
s := &composeService{}
content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{
Name: "db_password",
Content: "inline-value",
Driver: copyDriver,
File: filepath.Join(t.TempDir(), "missing.txt"),
}, secretMount)
assert.NilError(t, err)
assert.Equal(t, content, "inline-value")
}

// Without driver: copy, a File-only source resolves to no content: it stays
// exclusively the bind-mount path's responsibility (buildContainerSecretMounts).
func TestResolveFileContent_PlainFileIsNotCopied(t *testing.T) {
s := &composeService{}
content, err := s.resolveFileContent(&types.Project{}, types.FileObjectConfig{
Name: "db_password",
File: filepath.Join(t.TempDir(), "secret.txt"),
}, secretMount)
assert.NilError(t, err)
assert.Equal(t, content, "")
}
29 changes: 29 additions & 0 deletions pkg/e2e/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
package e2e

import (
"os"
"path/filepath"
"testing"
)

Expand All @@ -39,3 +41,30 @@ func TestSecretFromInclude(t *testing.T) {
ComposeCmd("run", "included"),
OutputContains("this-is-secret"))
}

// https://github.com/docker/compose/issues/11867
// secrets.*.driver: copy must deliver a one-time snapshot of the CLIENT's
// local file, read and copied into the container — not a live bind mount
// from a path on the Docker host, which breaks entirely once the daemon is
// remote. Editing the client's file after `up` must not reach the running
// container: that's the behavioral difference from the default bind mount,
// and the whole point of opting in.
func TestSecretCopyDriver(t *testing.T) {
s := NewScenario(t, "secrets.*.driver: copy must copy the client's file once, not bind-mount it live")
s.Step("up copies the secret's original content into the container",
ComposeCmd("up", "-d"),
ServiceState("test", "running")).
Step("the container holds the original content",
ComposeCmd("exec", "test", "cat", "/run/secrets/db_password"),
StdoutContains("original-secret"))

err := os.WriteFile(filepath.Join(s.Dir(), "secret.txt"), []byte("changed-secret"), 0o644)
if err != nil {
t.Fatalf("updating client secret file: %v", err)
}

s.Step("the running container's copy is unaffected by the client-side edit",
ComposeCmd("exec", "test", "cat", "/run/secrets/db_password"),
StdoutContains("original-secret"),
OutputNotContains("changed-secret"))
}
11 changes: 11 additions & 0 deletions pkg/e2e/testdata/TestSecretCopyDriver/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
test:
image: alpine
command: sleep infinity
secrets:
- db_password

secrets:
db_password:
file: ./secret.txt
driver: copy
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestSecretCopyDriver/secret.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
original-secret