extension.md gains the Image distribution section: invocation contract (--image/--digest/--created/--source/--policy), the get-image request and its chunked image-stream answer (zero-chunk terminated, stdin exclusivity and drain-before-next-answer contract), the two-branch decision guidance (digest as identity, created as ordering fallback, reproducible builds caveat), and the support-by-presence metadata convention is now stated as the general rule. The example provider implements pull end to end — request, stock chunked reader, hash recorded at PROVIDER_PULL_MARKER — and the e2e scenario locks the whole path: up builds the service image and streams it to the provider under the local verdict, compose pull re-invokes it under the freshness contract. Signed-off-by: Nicolas De Loof <[email protected]>
24 KiB
About
The Compose application model defines service as an abstraction for a computing unit managing (a subset of)
application needs, which can interact with other services by relying on network(s). Docker Compose is designed
to use the Docker Engine ("Moby") API to manage services as containers, but the abstraction could also cover
many other runtimes, typically cloud services or services natively provided by host.
The Compose extensibility model has been designed to extend the service support to runtimes accessible through
third-party tooling.
Architecture
Compose extensibility relies on the provider attribute to select the actual binary responsible for managing
the resource(s) needed to run a service.
database:
provider:
type: awesomecloud
options:
type: mysql
size: 256
name: myAwesomeCloudDB
provider.type tells Compose the binary to run, which can be either:
- Another Docker CLI plugin (typically,
modelto rundocker-model) - An executable in user's
PATH
If provider.type doesn't resolve into any of those, Compose will report an error and interrupt the up command.
To be a valid Compose extension, provider command MUST accept a compose command (which can be hidden)
with subcommands up and down. It MAY additionally implement a stop subcommand to support docker compose stop,
and a pull subcommand to take part in image distribution (see Image distribution).
Optional subcommands are declared through the provider metadata: the presence of the command block is what
opts the provider in.
Up lifecycle
To execute an application's up lifecycle, Compose executes the provider's compose up command, passing
the project name, service name, and additional options. The provider.options are translated
into command line flags. For example:
awesomecloud compose --project-name <NAME> up --type=mysql --size=256 "database"
Note:
project-nameshould be used by the provider to tag resources set for project, so that later execution withdownsubcommand releases all allocated resources set for the project.
Communication with Compose
Providers can interact with Compose using stdout as a channel, sending JSON line delimited messages.
JSON messages MUST include a type and a message attribute.
An unknown message type fails the command: a provider that requires a message the running Compose does not
support must fail loudly rather than degrade silently. To let a provider adapt instead, Compose announces the
message types it accepts in the COMPOSE_PROVIDER_MESSAGES environment variable of the provider process, as a
comma-separated list (e.g. error,info,setenv,rawsetenv,debug,publish-endpoint,get-service-config,get-relay-info):
check membership before emitting an optional message.
{ "type": "info", "message": "preparing mysql ..." }
type can be either:
info: Reports status updates to the user. Compose will render message as the service state in the progress UIerror: Lets the user know something went wrong with details about the error. Compose will render the message as the reason for the service failure.setenv: Lets the plugin tell Compose how dependent services can access the created resource. The variable is automatically prefixed with the service name. See next section for further details.rawsetenv: Same assetenv, but the variable is injected as-is without the service name prefix. Useful when applications require exact variable names that cannot be altered.debug: Those messages could help debugging the provider, but are not rendered to the user by default. They are rendered when Compose is started with--verboseflag.get-service-config: Asks Compose for the resolved configuration of the service the provider manages. See next section.get-relay-info: Asks Compose what address a locally-run endpoint should bind so the relay deployed for this service can reach it — Compose owns the platform knowledge, the provider just binds what is announced. Only meaningful for a provider running its service locally; a provider backing the service with a remote resource never needs it. See Binding a local endpoint for the relay.publish-endpoint: Declares where a network endpoint of the provider's resource is actually reachable. The message is"<container-port>=<host>:<port>"— the port consumers know on the left, the real location on the right, as seen FROM THE PROVIDER'S HOST (typically a port published on the host):TCP only; the message may be repeated, one per port. When a provider publishes at least one endpoint, Compose deploys a relay container in place of the service so that dependents reach the resource at the compose-native address — see Compose-native addressing with{ "type": "publish-endpoint", "message": "80=localhost:49152" }publish-endpoint.
Requesting the service configuration
A provider can ask the running Compose process for the resolved definition of the service it manages —
the exact model Compose is executing, not a re-resolution. The request is a regular JSON line on stdout:
{ "type": "get-service-config" }
Compose answers on the provider's stdin with one JSON line: the resolved, canonical JSON of the service —
the same shape as this service's entry in docker compose config --format json, after interpolation and
normalization:
{ "image": "mysql:8", "environment": { "...": "..." } }
There is no parameter: a provider can only obtain the definition of its own service. The message can be sent several times; each occurrence is answered with one line.
Compose versions that predate this message treat it as a protocol error and abort the command, and never
write anything to the provider's stdin (the provider reads EOF). A provider that requires the service
configuration should treat EOF as "this Compose version does not support provider requests" and report an
actionable error; a provider that can operate without it should simply not send the message.
sequenceDiagram
Shell->>Compose: docker compose up
Compose->>Provider: compose up --project-name=xx --foo=bar "database"
Provider--)Compose: json { "info": "pulling 25%" }
Compose-)Shell: pulling 25%
Provider--)Compose: json { "info": "pulling 50%" }
Compose-)Shell: pulling 50%
Provider--)Compose: json { "info": "pulling 75%" }
Compose-)Shell: pulling 75%
Provider--)Compose: json { "setenv": "URL=http://cloud.com/abcd:1234" }
Compose-)Compose: set DATABASE_URL
Provider--)Compose: json { "rawsetenv": "SECRET_KEY=xxx" }
Compose-)Compose: set SECRET_KEY (as-is)
Provider-)Compose: EOF (command complete) exit 0
Compose-)Shell: service started
Image distribution
A provider-backed service can declare build or image like any other service; the image then has to reach
the provider's runtime, which may be nowhere near the local daemon. Providers opt into image distribution by
declaring a pull block in their metadata output — like stop, the presence of the block is the
declaration of support. Providers without it keep managing images on their own during up.
When the provider declares pull, Compose invokes it during the image phase of up (after any build) and on
docker compose pull:
awesomecloud compose --project-name <NAME> pull --image=<ref> --source=<verdict> --policy=<policy> [--digest=<id> --created=<time>] "database"
--image: the image reference as Compose resolved it (theimageattribute, or<project>-<service>for a build-only service).--digest/--created: the state of the local daemon cache, present only when the image exists there. They describe the cache, they are not instructions: persist them as the bookkeeping keys of what you ingest — the digest as identity test,createdas the ordering fallback for a backend that cannot preserve digests. Beware that reproducible builds can freezecreated, so a comparable digest always wins over it.--sourceis the authority verdict, computed by Compose from the model and the invocation (pull_policy,--build, what the current run just built), so providers never re-implement that arbitration:local: the desired state is the local daemon's image. Compare your bookkeeping with the announced digest/created; when they differ, request the bytes withget-image.registry: resolve the reference upstream — this includes the common workflow wherebuildis only the recipe CI uses to publish the image that consumers pull. The local facts are an optimization, never an obligation.
--policy:missing(theuppath): a usable version present in your runtime suffices;always(docker compose pull): ensure your runtime holds the latest version of the authority.
Requesting the image bytes
During pull, the provider can ask Compose for the image content with a regular JSON line on stdout
(platform is optional and narrows a multi-platform image):
{ "type": "get-image", "message": "<image ref>", "platform": "linux/arm64" }
Compose answers on the provider's stdin with one JSON line:
{ "type": "image-stream", "encoding": "chunked", "media-type": "application/x-tar" }
followed — unless the line carries an error field instead — by the image tar encoded as HTTP/1.1 chunked
data (RFC 9112 §7.1): every block of data prefixed by its length, terminated by the zero-length chunk. Unlike
an HTTP message there is no trailer section nor final CRLF — the next byte after the zero chunk belongs to the
next stdin answer. Length-prefixed framing needs no in-band delimiter (any byte value can appear inside a tar), and
any language's stock chunked-body reader consumes it — Go providers can use httputil.NewChunkedReader. A
stream that ends without the terminating zero chunk was aborted and must be discarded — Compose closes the
answer channel after an aborted transfer, so the truncation is always observable as EOF. The tar is what
docker image save produces: feed it to docker load or whatever your runtime ingests.
As during stop, any setenv, rawsetenv or publish-endpoint message emitted during pull is accepted
but ignored: dependent services are not being configured in this phase.
The stream is exclusive on stdin for its whole duration — the chunked body must be contiguous, so answers to
any other request emitted meanwhile are delivered after it. Drain the announced stream completely before
expecting another answer.
Connection to a service managed by a provider
A service in the Compose application can declare dependency on a service managed by an external provider:
services:
app:
image: myapp
depends_on:
- database
database:
provider:
type: awesomecloud
When the provider command sends a setenv JSON message, Compose injects the specified variable into any dependent service,
automatically prefixing it with the service name. For example, if awesomecloud compose up returns:
{"type": "setenv", "message": "URL=https://awesomecloud.com/db:1234"}
Then the app service, which depends on the service managed by the provider, will receive a DATABASE_URL environment variable injected
into its runtime environment.
When the provider command sends a rawsetenv JSON message, Compose injects the variable as-is without any prefix:
{"type": "rawsetenv", "message": "SECRET_KEY=xxx"}
The app service will receive SECRET_KEY exactly as specified, regardless of the provider service name.
This is useful when injecting secrets or configuration values that must match exact variable names expected by
applications or frameworks.
Unlike setenv, which avoids collisions through automatic prefixing, rawsetenv keys are the provider's
responsibility to keep unique. If a rawsetenv key collides with a variable already set on the dependent service,
the existing value is overwritten and Compose logs a warning. This includes variables declared by the user in the
service environment section as well as values emitted by other providers. Providers that are not linked by a
depends_on relationship may run concurrently, so when several of them emit the same rawsetenv key the resulting
value is not deterministic.
Note: The
compose upprovider command MUST be idempotent. If resource is already running, the command MUST set the same environment variables to ensure consistent configuration of dependent services.
Compose-native addressing with publish-endpoint
Environment-variable injection makes the consumer aware of the provider: the application has to read
DATABASE_URL instead of connecting to database the way it would reach any container-backed service. When the
provider's resource is reachable through a TCP endpoint, publish-endpoint removes that coupling: the provider
declares where each port of the resource is actually reachable, and Compose deploys a relay container in
place of the service. Dependents then connect to the compose-native address — <service>:<container-port>,
e.g. http://database:80 — with no injected variables involved, so the same application configuration works
whether the service runs as a container or through a provider.
sequenceDiagram
participant Compose
participant Provider
participant resource as managed resource<br/>(provider's host)
participant relay as relay container<br/>network alias: database
participant app as app container
Compose->>Provider: compose up --project-name=xx "database"
Provider->>resource: provision, publish a port on the host
Provider--)Compose: json { "type": "publish-endpoint", "message": "80=localhost:49152" }
Provider-)Compose: EOF (command complete) exit 0
Compose->>relay: deploy on the dependents' networks,<br/>forwarding 80 → host.docker.internal:49152
Compose->>app: start
app->>relay: connect to database:80
relay->>resource: forward to host.docker.internal:49152
The provider reports each endpoint as seen from its own host — typically a port published on localhost — and
does not need to know how containers reach that host: the relay translates a loopback (or unspecified) upstream
host into host.docker.internal — resolved through the host-gateway extra_host Compose injects — while
routable addresses pass through untouched.
The relay is a minimal TCP forwarder (docker/compose-relay — set COMPOSE_RELAY_IMAGE to pull the image from
an internal registry instead of Docker Hub) joining the networks of the services that depend on the provider
service, aliased with the service name. It is a regular project container (standard compose labels, canonical
<project>-<service>-1 name), so ps, logs, stop and down treat it as the service; it additionally
carries the com.docker.compose.relay label identifying its role, and process-level commands (exec, cp)
refuse it. The relay is recreated when the published endpoints change, and removed by down like any project
container.
Binding a local endpoint for the relay
A provider that runs its service on the local host has to pick the address its endpoint binds, and on a
standalone Linux engine no address is both relay-reachable and off the LAN by default: host.docker.internal
resolves there to the bridge gateway, which a loopback-only listener cannot accept, while the wildcard exposes
the port on every host interface. (Docker Desktop has no such dilemma — its proxy reaches the host's loopback.)
get-relay-info resolves this: sending it is itself the provider's declaration that it binds locally, and
Compose reacts by creating a dedicated relay-link network — an internal:true bridge, scoped to this one
provider service, joined by nothing but the relay container — then answers with one JSON line naming it
alongside the address a locally-run endpoint should bind so the relay can reach it:
{ "type": "get-relay-info" }
{"networks":[{"name":"myproject_database_relay","gateway":"172.20.0.1"}]}
A provider backing a remote resource (an Amazon RDS instance, say) has no local endpoint to bind and simply
never sends get-relay-info: no relay-link network is created for it, and its publish-endpoint reports the
remote resource's own address unchanged.
sequenceDiagram
participant Compose
participant Provider
participant net as relay-link network<br/>(internal, per-service)
participant relay as relay container
rect rgb(235, 245, 255)
note over Compose,net: Provider running its service locally
Compose->>Provider: compose up --project-name=xx "database"
Provider->>Compose: json { "type": "get-relay-info" }
Compose->>net: create (or reuse) the dedicated<br/>relay-link network for "database"
Compose--)Provider: json {"networks":[{"name":"myproject_database_relay",<br/>"gateway":"172.20.0.1"}]}
Provider->>Provider: bind local endpoint to 172.20.0.1
Provider--)Compose: json { "type": "publish-endpoint",<br/>"message": "80=172.20.0.1:49152" }
Compose->>relay: deploy, join dependents' networks<br/>AND the relay-link network
end
sequenceDiagram
participant Compose
participant Provider
participant resource as remote resource<br/>(e.g. Amazon RDS)
participant relay as relay container
rect rgb(255, 245, 235)
note over Compose,resource: Provider backing a remote resource
Compose->>Provider: compose up --project-name=xx "database"
Provider->>resource: provision
note over Provider: no local endpoint to bind:<br/>get-relay-info is never sent
Provider--)Compose: json { "type": "publish-endpoint",<br/>"message": "80=resource.example.com:5432" }
Compose->>relay: deploy, join dependents' networks only<br/>(no relay-link network created)
end
Compose owns the platform knowledge behind the announced address: on a standalone engine it is the relay-link
network's IPv4 gateway — an address the provider's host owns on that dedicated bridge, reachable from the relay
(same-bridge local delivery) and joined by nothing else: no other container is a member of that network, and none
of the project's service networks includes it. This is isolation by network membership, not something the
address alone enforces: the gateway is an address of the host itself, and a plain socket bound to it restricts
by destination address, not by the interface traffic arrives on (Linux's weak host model) — so a container on
another bridge, or a LAN peer routing through the host, can still reach such a listener by IP. To keep the
endpoint reachable from the relay only, a provider should bind only the announced gateway — never a
wildcard or any other address — and pin the socket to the relay-link bridge's own network interface
(SO_BINDTODEVICE on Linux), so traffic arriving on any other interface is not accepted. Without that pin,
treat the endpoint as reachable by any local container. Under Docker
Desktop the announced address is 127.0.0.1 — the network lives inside the VM, and the host's own
loopback is, factually, where a host process is reached through the Desktop proxy, so no dedicated network is
created there. The provider simply binds the announced gateway and publishes the endpoint exactly as bound: a
routable address passes to the relay untouched, a loopback one is announced as localhost (translated to
host.docker.internal). The gateway field may be absent when it cannot be resolved (exotic network drivers,
IPv6-only IPAM): fall back to a bind of your choice. Best-effort by design. The relay-link network is removed
along with the relay container when the service stops publishing endpoints, and by down like any other
project resource.
The name field is an opaque identifier, not a promise that a Docker network by that name exists: under Docker
Desktop it is the literal string "desktop", which no docker network inspect or NetworkConnect call will
ever resolve. A provider must use it only for logging, never as an engine-level network reference — gateway is
the only field it needs to bind and publish correctly.
Down lifecycle
down lifecycle is equivalent to up with the <provider> compose --project-name <NAME> down <SERVICE> command.
The provider is responsible for releasing all resources associated with the service.
Stop lifecycle
When the user runs docker compose stop, Compose invokes <provider> compose --project-name <NAME> stop <SERVICE> for each
provider-backed service in reverse dependency order. The provider should pause the resource without releasing it, so a later
docker compose up can resume it (note that docker compose start only restarts existing containers and does not invoke
provider hooks). Any setenv or rawsetenv JSON message returned during stop is ignored, since dependent services are also stopping.
The stop hook is opt-in: Compose invokes it only when the provider declares a stop block in its metadata subcommand
output. Providers that do not advertise stop in metadata (or do not implement the metadata subcommand at all) are
silently skipped during docker compose stop, preserving backward compatibility with providers that pre-date this hook.
The --timeout flag of docker compose stop applies only to container services; provider stop hooks are not subject to
this timeout and are responsible for managing their own shutdown duration.
Provide metadata about options
Compose extensions MAY optionally implement a metadata subcommand to provide information about the parameters accepted by the up and down commands.
The metadata subcommand takes no parameters and returns a JSON structure on the stdout channel that describes the parameters accepted by both the up and down commands, including whether each parameter is mandatory or optional.
awesomecloud compose metadata
The expected JSON output format is:
{
"description": "Manage services on AwesomeCloud",
"up": {
"parameters": [
{
"name": "type",
"description": "Database type (mysql, postgres, etc.)",
"required": true,
"type": "string"
},
{
"name": "size",
"description": "Database size in GB",
"required": false,
"type": "integer",
"default": "10"
},
{
"name": "name",
"description": "Name of the database to be created",
"required": true,
"type": "string"
}
]
},
"down": {
"parameters": [
{
"name": "name",
"description": "Name of the database to be removed",
"required": true,
"type": "string"
}
]
},
"stop": {
"parameters": [
{
"name": "name",
"description": "Name of the database to be stopped",
"required": true,
"type": "string"
}
]
},
"pull": {
"parameters": []
}
}
The top elements are:
description: Human-readable description of the providerup: Object describing the parameters accepted by theupcommanddown: Object describing the parameters accepted by thedowncommandstop: Object describing the parameters accepted by thestopcommand (optional)pull: Object describing the parameters accepted by thepullcommand (optional — declaring the block is what opts the provider into image distribution; the flags Compose injects need not be listed)
And for each command parameter, you should include the following properties:
name: The parameter name (without--prefix)description: Human-readable description of the parameterrequired: Boolean indicating if the parameter is mandatorytype: Parameter type (string,integer,boolean, etc.)default: Default value (optional, only for non-required parameters)enum: List of possible values supported by the parameter separated by,(optional, only for parameters with a limited set of values)
This metadata allows Compose and other tools to understand the provider's interface and provide better user experience, such as validation, auto-completion, and documentation generation.
Examples
See example for illustration on implementing this API in a command line