You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Support Mounts and Secrets in Compose Provider Extensions #14163
Compose provider extensions are useful for provisioning or configuring resources that are not managed directly by Compose. They can also inject information about those resources into the application, commonly through environment variables—for example, providing managed database credentials.
However, credentials and other sensitive values are often better distributed as mounted files rather than environment variables. Environment variables can unintentionally leak through process inspection, debugging output, logs, crash reports, or other tooling.
At present, Compose providers appear to only have the ability to add or override environment variables. There does not seem to be a way for a provider to inject a mount, secret, or other file-based credential into the resulting Compose service.
Proposed Feature
Allow Compose provider extensions to contribute file-based (secrets) resources such as, and overall align them better with existing capabilities like cdi extensions which in principle can mutate containers at runtime with additional volume and/or bind mounts.
For example, a database provider could expose credentials through a secret:
services:
app:
providers:
database:
type: managed-db
secrets:
- db_password
secrets:
db_password:
# Provided by the provider
The application could then consume the credential from the mounted secret file rather than receiving it through an environment variable.
Motivation
This would allow providers to follow more secure secret-distribution practices while retaining the convenience of provider extensions. In particular, it would: better align the feature with security recommendations/best practices, and align the feature for images that exclusively be used with such password files.
Following up on this now that #14230 is proposing the mount/secret injection mechanism itself. One thing I'd like to explore before we settle on that as the shape: right now it relies purely on depends_on + implicit injection (a provider's plugin message gets appended to the Volumes/Secrets of any service that depends on it), which has two downsides — it's invisible from the consuming service's own compose.yaml entry, and nothing disambiguates which provider fills which secret/volume when a service depends on several.
Alternative: let a top-level secrets:/volumes: entry declare who populates it:
secrets:
db_password:
populated_by: database # a service with a `provider:` definitionservices:
database:
provider:
type: managed-dbapp:
secrets:
- db_password # no depends_on needed — derived automatically
populated_by alone would carry three responsibilities, each close to something the codebase already does:
1. Skip normal validation.loader/validate.go currently requires a non-external secret to declare file or environment. populated_by becomes a second exemption alongside External — a secret filled at runtime by a named provider doesn't need any of the usual sources declared upfront.
2. Derive the dependency edge automatically. Compose already does exactly this for build.additional_contexts — addBuildDependencies (pkg/compose/build.go) scans for the service: prefix and injects an implicit build dependency. Same pattern here: scan every service's secrets:/volumes: references, and if the resolved definition declares populated_by: X, inject an implicit (required) depends_on: X.
3. Resolution — already free, for secrets.resolveFileContent (pkg/compose/secrets.go) already treats Content as the primary resolution source, and already treats an empty Content (with no Environment either) as "nothing to inject, skip silently" — no error. That's exactly the behavior needed for an unpopulated dynamic secret. The provider side just needs to end up setting Content on project.Secrets[name], and the existing injection path takes it from there with no new code.
For volumes there's no Content, but the same signal exists: the classic way to make a named Docker volume point at a location a provider owns is a local-driver bind — driver_opts: {type: none, o: bind, device: <path>}. A provider populating that needs no new creation code path either: createVolume (pkg/compose/create.go) already passes Driver/DriverOpts straight through to VolumeCreate. The "still empty" signal for a volume becomes "no driver/opts set", the same role Content == "" plays for a secret.
Ordering falls out for free too: secrets/volumes are injected per-container at ContainerStart time, inside the existing dependency-ordered startup loop — not a global upfront pass — so once the derived edge exists, the provider is guaranteed to have already run by the time the consumer's container starts.
One gap to close either way: since empty Content/unset driver opts are silently treated as "nothing to inject" today, a provider that fails to actually populate a secret/volume it's declared populated_by for would currently produce a container silently missing it. This should fail the dependent service's startup explicitly instead — symmetric to service_completed_successfully.
@glours curious what you think of this shape vs. the pure depends_on-based injection in #14230 — happy to write it up more formally if it seems worth pursuing alongside (or instead of) that PR.
Activity
ndeloof commentedon Sep 16, 2026
Following up on this now that #14230 is proposing the mount/secret injection mechanism itself. One thing I'd like to explore before we settle on that as the shape: right now it relies purely on
depends_on+ implicit injection (a provider's plugin message gets appended to theVolumes/Secretsof any service that depends on it), which has two downsides — it's invisible from the consuming service's owncompose.yamlentry, and nothing disambiguates which provider fills which secret/volume when a service depends on several.Alternative: let a top-level
secrets:/volumes:entry declare who populates it:populated_byalone would carry three responsibilities, each close to something the codebase already does:1. Skip normal validation.
loader/validate.gocurrently requires a non-externalsecret to declarefileorenvironment.populated_bybecomes a second exemption alongsideExternal— a secret filled at runtime by a named provider doesn't need any of the usual sources declared upfront.2. Derive the dependency edge automatically. Compose already does exactly this for
build.additional_contexts—addBuildDependencies(pkg/compose/build.go) scans for theservice:prefix and injects an implicit build dependency. Same pattern here: scan every service'ssecrets:/volumes:references, and if the resolved definition declarespopulated_by: X, inject an implicit (required)depends_on: X.3. Resolution — already free, for secrets.
resolveFileContent(pkg/compose/secrets.go) already treatsContentas the primary resolution source, and already treats an emptyContent(with noEnvironmenteither) as "nothing to inject, skip silently" — no error. That's exactly the behavior needed for an unpopulated dynamic secret. The provider side just needs to end up settingContentonproject.Secrets[name], and the existing injection path takes it from there with no new code.For volumes there's no
Content, but the same signal exists: the classic way to make a named Docker volume point at a location a provider owns is alocal-driver bind —driver_opts: {type: none, o: bind, device: <path>}. A provider populating that needs no new creation code path either:createVolume(pkg/compose/create.go) already passesDriver/DriverOptsstraight through toVolumeCreate. The "still empty" signal for a volume becomes "no driver/opts set", the same roleContent == ""plays for a secret.Ordering falls out for free too: secrets/volumes are injected per-container at
ContainerStarttime, inside the existing dependency-ordered startup loop — not a global upfront pass — so once the derived edge exists, the provider is guaranteed to have already run by the time the consumer's container starts.One gap to close either way: since empty
Content/unset driver opts are silently treated as "nothing to inject" today, a provider that fails to actually populate a secret/volume it's declaredpopulated_byfor would currently produce a container silently missing it. This should fail the dependent service's startup explicitly instead — symmetric toservice_completed_successfully.@glours curious what you think of this shape vs. the pure
depends_on-based injection in #14230 — happy to write it up more formally if it seems worth pursuing alongside (or instead of) that PR.