A module is a self-contained building block of the platform, such as an orchestrator, warehouse, transformation engine, BI tool, validation tool, identity provider, or secrets provider.
Each module should be independently understandable, minimally reusable, and composable into one or more stack profiles.
Getting started? See docs/from-docker-to-cds-profile.md for a complete walkthrough on creating modules from existing docker-compose services.
Each module should:
- have a clearly defined responsibility
- expose a predictable interface to other modules
- be runnable through Docker Compose
- document its required configuration
- avoid hidden dependencies where possible
A module is defined by a single module.yaml file. Everything the validator, planner, and renderer need (runtime service definition, configuration schema, contracts, and the Docker Compose implementation) lives inline in that one file.
modules/<category>/<name>/
└── module.yaml
Optional, module-local supporting files:
modules/<category>/<name>/
├── module.yaml
├── README.md # optional — human-readable notes
├── config/ # optional — static configuration files
└── scripts/ # optional — bootstrap, init, or helper scripts
| File/Directory | Required | Purpose |
|---|---|---|
module.yaml |
yes | Source of truth: metadata, runtime service, config schema, contracts, and the inline Compose implementation |
README.md |
no | Optional human-readable notes; not read by the validator |
config/ |
no | Static configuration files |
scripts/ |
no | Bootstrap, init, or helper scripts |
data/ |
no | Local development data or seeds, if intentionally included |
There is no separate compose.yml or .env.example file. The Docker Compose definition lives inline at spec.implementation.compose, and configuration values are declared and validated through spec.configSchema rather than an example env file. See Configuration below.
A representative stable module (modules/warehouse/postgres/) and a representative experimental module (modules-experimental/orchestration/airflow/) both follow this same single-file structure; "experimental" is a directory convention (modules-experimental/), not a different schema.
A module should own:
- its service definition and Compose implementation
- its module-specific configuration schema
- its container image definition, if needed
- its own setup notes and runtime assumptions
A module should not own:
- top-level profile orchestration
- global bootstrap logic
- unrelated shared utilities
- configuration for other modules
Each module declares its Docker Compose implementation inline, under spec.implementation:
spec:
implementation:
kind: docker-compose
compose:
services:
postgres:
image: postgres:18@sha256:...
# ...
volumes:
postgres-data:
enabledFrom: spec.config.storage.enabledkind is required; docker-compose is the only implementation kind in use today. compose is a normal Docker Compose service/volume/network definition, with two CDS-specific extensions:
- template placeholders, resolved at render time from four namespaces:
${config.<field>}reads the module configuration validated byspec.configSchema${service.host}resolves to the module instanceiddeclared in the profile, not to a service name underspec.implementation.compose.services; modules with multiple Compose services receive the same moduleidfor this placeholder${bindings.<contract>.<field>}reads a field from a contract declared inspec.consumesand resolved by the profile${secrets.<alias>}resolves a profile secret alias to a Docker Compose runtime placeholder such as${CDS_DB_PASSWORD}; CDS never embeds the secret value${certificates.<alias>.cert}/${certificates.<alias>.key}(issue #578) resolve one half of a profile certificate alias declared underspec.certificates.valuesto its${CDS_*}runtime placeholder. CDS never generates, reads, or embeds certificate or private key material: the alias only names the environment variables the external secret/cert provider supplies, so an undeclared alias, a barecertificates.<alias>reference, or an unknown trailing part fails validation withE129instead of silently not substituting
enabledFrom/conditionallyEnabledFrom: <json-path>, which include or drop a volume, service, or healthcheck based on a boolean resolved from the profile's config (seepostgres'sstorage.enabledandhealthcheck.enabledabove). The marker's shape is enforced: it must be a non-empty string on services, volume definitions, volume-mount entries, and healthchecks — anything else (e.g. the booleanenabledFrom: true, unlikeproduction: true) crashes the renderer and now fails validation withE145; on entries of any other service list field the marker is not implemented, so anyenabledFromkey there fails closed as well. In thekubernetesimplementation block the same rules apply (productiongates on volume and configMap entries,enabledFromon volume entries and workloads; markers elsewhere in the block fail closed). Both render targets re-check marker shapes when rendering a stored plan (plans can be loaded from files).
When authoring spec.implementation.compose:
- define only the services that belong to that module
- use stable, descriptive service names
- include health checks where practical, guarded with
enabledFromif optional - attach services to the shared profile network
- expose only the ports needed for local use, bound to
127.0.0.1unless the profile requires otherwise - use named volumes for persistent state where appropriate
Prefer:
- explicit environment variables
- explicit dependencies
- small, focused service definitions
Avoid:
- hidden reliance on undeclared services
- hardcoded paths outside the repo unless clearly documented
- broad coupling to one specific profile
CDS resolves module placeholders while rendering a profile. Each placeholder has a specific source and availability:
| Placeholder | Source | Available when |
|---|---|---|
${config.<field>} |
The module configuration, after schema validation | The module is being rendered |
${bindings.<contract>.<field>} |
A consumed contract resolved by the profile | The contract is declared in spec.consumes and has been bound |
${service.host} |
The module instance id from the profile |
The module is being rendered |
${secrets.<alias>} |
A profile secret alias | The alias is declared in spec.secrets |
${certificates.<alias>.cert} / ${certificates.<alias>.key} |
One half of a profile certificate alias | The alias is declared in spec.certificates |
${ifNonempty:<path>,<prefix>,<suffix>} |
Conditional interpolation of a placeholder path | The module is being rendered |
For example, a cache module can publish a contract and consume it from another module without hard-coding either module's host or port:
# modules/cache/module.yaml
spec:
provides:
- name: cache-service
contract:
kind: cache-service
spec:
host: ${service.host}
port: ${config.port}
implementation:
kind: docker-compose
compose:
services:
cache:
ports:
- "127.0.0.1:${config.port}:6379"
# modules/worker/module.yaml
spec:
consumes:
- name: cache
contract: cache-service
implementation:
kind: docker-compose
compose:
services:
worker:
environment:
CACHE_HOST: ${bindings.cache.host}
CACHE_PORT: ${bindings.cache.port}With a profile instance such as id: cache and port: 6380, the first module
publishes host: cache and port: 6380; the worker receives those values when
its cache binding is resolved. ${secrets.<alias>} is different: it remains
a Docker Compose runtime variable (for example ${CDS_DB_PASSWORD}), so CDS
never embeds the secret value in generated output. See the
profile guide for a complete stack example.
Modules that interact with an HTTP, database, or cache endpoint can declare a
TLS requirement on a consumes entry, provider-neutral and opt-in
(issue #577):
spec:
consumes:
- name: metadata-database
contract:
kind: sql-database
required: true
mappedFrom: spec.config.metadataDatabase
tls: requiredThe provider opts in from its provide entry's contract.spec:
spec:
provides:
- name: sql-database
contract:
kind: sql-database
spec:
host: ${service.host}
port: ${config.port}
tls: trueSemantics and validation paths:
tls: requiredis the only requirement value today; any other value fails module schema validation (E021) so a typo cannot silently disable the check. Omitting the key places no TLS requirement, so existing profiles andmodule.yamldocuments stay valid unless they explicitly opt in.- The provider's advertisement is a boolean literal (
true/false) or a pure${config.<path>}reference that resolves against the producer instance's profile config. Because validation runs before the planner appliesconfigSchemadefaults, a config-driven advertisement only satisfies a requirement when the profile sets the referenced value explicitly. - A requirement is only satisfied by
tls: true; an absent field,false, or an unresolvable config reference fails closed withE128atcds validate(see Troubleshooting). - Like every other contract-spec field,
tlsflows into the consumer's resolved binding (${bindings.<name>.tls}) duringcds plan, so implementation templates can act on it.
The field is optional (required: false) in the shared contract documents
(shared/contracts/http-service.yaml, sql-database.yaml,
cache-service.yaml, reverse-proxy.yaml); file-database carries no TLS
field because it describes a bind-mounted file, not a network endpoint.
A module providing the reverse-proxy contract (e.g. Traefik) exposes public
routes through its config.routes entries instead of Docker label
autodiscovery: each entry declares a public host and a target module
id (or <module-id>.<contract-name> when the target provides several
http-service contracts). CDS resolves the target's http-service
contract, cross-checks the backend address against the compose services the
render actually produced, and writes deterministic file-provider
configuration next to the rendered compose file; invalid routing inputs
fail with E136 instead of silently generating broken config.
A route's optional certificate names a profile certificate alias
(spec.certificates.values, issue #578). The alias is only a name: CDS
never generates, reads, or embeds certificate material. For the alias to
work, the external secret/cert provider must place the files as
<alias>.crt and <alias>.key inside the proxy's certificate mount
(./certs/traefik/ for the Traefik module, mounted read-only at
/etc/traefik/certs/); the generated configuration wires those paths as
the router's certificate files. The alias is restricted to letters, digits
and hyphens because it interpolates into those file paths.
Relational database and cache providers advertise how (and whether) they
authenticate clients from their provide entry's contract.spec
(issue #206), mirroring the TLS advertisement:
# modules/warehouse/postgres/module.yaml
spec:
provides:
- name: sql-database
contract:
kind: sql-database
spec:
host: ${service.host}
port: ${config.port}
authentication: ${config.authentication.hostAuth} # e.g. scram-sha-256
# modules/cache/keydb/module.yaml
spec:
provides:
- name: cache-service
contract:
kind: cache-service
spec:
authentication: ${ifNonempty:config.passwordFrom,required,}For a sql-database provider the advertisement is the host (TCP)
authentication method (e.g. scram-sha-256, md5, trust); for a
cache-service provider it is required when the service rejects
unauthenticated clients and empty when it does not.
Production profiles (metadata.environment: production) fail validation
without them:
- a
sql-databaseprovider without an advertisement, or advertisingtrust, fails withE139;md5validates but warns (W105) because it is a deprecated password method; - a
cache-serviceprovider that does not advertiserequiredfails withE137; - every credential-named config field of those providers (e.g.
passwordFrom,superuserPasswordFrom) must be asecrets.<alias>reference whose alias is declaredrequired: true, rejecting plaintext literals, empty values, and${CDS_*}profile inputs withE138.
Like the TLS advertisement, a ${config.<path>} reference only satisfies
the policy when the profile sets the value explicitly (validation runs
before configSchema defaults apply), so production profiles declare
their authentication method explicitly. Non-production profiles are
unaffected: a development cache may stay unauthenticated.
Connection contracts carry credentials safely across consumers: the
planner turns secrets.<alias> references into Docker Compose runtime
placeholders (${CDS_CACHE_PASSWORD}) inside the resolved contract, so a
binding's connectionUri (e.g. redis://:${CDS_CACHE_PASSWORD}@keydb:6379)
flows into orchestration and BI consumers without CDS ever embedding a
resolved secret value.
For configuration values that require delimiters or prefixes only when set (such as optional passwords in connection URIs), CDS supports conditional interpolation via ${ifNonempty:<path>,<prefix>,<suffix>}:
- Syntax:
${ifNonempty:<path>,<prefix>,<suffix>}where<path>is any valid placeholder expression (e.g.config.passwordFromorbindings.db.password),<prefix>is prepended to the resolved value, and<suffix>is appended. - Semantics: Evaluates
<path>. If the resolved value isNone, empty, or whitespace-only after stripping, it emits an empty string"". Otherwise, it returnsprefix + value + suffix. - Example: In
modules/cache/keydb/module.yaml, constructing an authenticated Redis connection URI:
connectionUri: "redis://${ifNonempty:config.passwordFrom,:,@}${service.host}:${config.port}"If config.passwordFrom resolves to a secret placeholder such as
${CDS_CACHE_PASSWORD}, it renders
redis://:${CDS_CACHE_PASSWORD}@keydb:6379. If config.passwordFrom is
unset, both prefix and suffix are skipped, rendering
redis://keydb:6379.
Modules mark the dividing line between production-worthy pieces and
learning/mockup-oriented pieces with the production tag, evaluated
against the profile's metadata.environment (production vs anything
else). The learning variant must stay especially easy to install — the
pinned stock image and no local builds — while production profiles gain
the hardened pieces.
Two forms of the tag, distinguished by type:
production: trueon a service, volume, or volume-mount entry keeps the entry only in production profiles;production: falsekeeps it only outside them. Entries carrying bothproductionandenabledFrommust match both gates. The marker's shape is enforced: on services and volume definitions it must be a boolean or a mapping; on volume-mount entries it must be a boolean (or absent) — this includes mount entries nested inside a service'sproduction: { ... }mapping. On entries of any other service list field (ports,labels, ...) no marker is implemented, so anyproductionkey there fails closed as well. Every violation fails validation withE144instead of silently leaking into the rendered output, and both render targets re-check marker shapes when rendering a stored plan (plans can be loaded from files).production: { ... }(mapping form) on a service marks the enclosed fields as production-only overrides of the service's base fields. A service is a single YAML key, so its learning and production variants cannot be two separate services; in a production profile the mapping is merged over the base service — mappings such asenvironmentmerge recursively, anything else (image,command,volumes) replaces the base value — and in other profiles it is ignored entirely. The base service stays the easy-to-install learning variant.
services:
postgres:
image: postgres:18@sha256:... # learning: pull only, no build
...
production: # production pieces
image: local/postgres:custom
build: { context: ../../../, dockerfile: images/postgres/Dockerfile }
command: [postgres, -c, archive_mode=on, ...]
environment: { ... } # merged over the base environment
postgres-backup:
production: true # production-only service
enabledFrom: config.backup.enabled
...The compose renderer drops mismatched entries before rendering; the
Kubernetes (helm) target is treated as production-only for now and always
renders the production variants. When a config option only works in
production (for example the postgres backup capability, whose sidecar is
production: true), validation fails closed instead of silently rendering
less than requested (E143 for config.backup.enabled outside a
production profile).
When a module requires a custom Docker image, the build context belongs in the images/ directory at the repository root, not inside the module directory. Reference it from spec.implementation.compose.services.<name>, the same place any other Compose service field goes:
images/<module-name>/
├── Dockerfile
└── requirements.txt # or other build context files
spec:
implementation:
compose:
services:
<service-name>:
build:
context: ../../../
dockerfile: images/<module-name>/Dockerfile
image: local/<module-name>:customcontext is relative to the module's own directory, so it points at the repository root (../../../) with dockerfile given as a root-relative path, not a path already inside images/<module-name>/. The image field assigns a local tag so Docker Compose can reference the built image consistently across services in the same module.
The Dagster module uses a custom image defined in images/dagster/. Shared
build support files (config generation, entrypoint, healthcheck, workspace,
requirements) live directly under images/dagster/, while each image variant
has its own Dockerfile under a base/ or hardened/ subfolder so the two
variants share everything except the Dockerfile itself:
images/dagster/
├── base/
│ └── Dockerfile # Debian/python:3.14-slim (default)
├── hardened/
│ └── Dockerfile # Alpine-based, minimal attack surface
├── entrypoint.sh
├── generate_config.py
├── healthcheck.py
├── requirements.txt
└── workspace.yaml
Referenced in modules/orchestration/dagster/module.yaml, where
config.image.variant (base or hardened) selects which Dockerfile is
built:
spec:
implementation:
compose:
services:
dagster-webserver:
build:
context: ../../../
dockerfile: images/dagster/${config.image.variant}/Dockerfile
image: local/dagster:customModules that use a standard upstream image without customization do not need an entry in images/ and should reference the image directly in the service's image field.
Each module declares the configuration it accepts as a JSON Schema under spec.configSchema. This is the validated source of truth for module config; there is no .env.example file.
spec:
configSchema:
type: object
additionalProperties: false
required:
- database
- username
- passwordFrom
- port
properties:
database:
type: string
minLength: 1
passwordFrom:
type: string
pattern: "^secrets\\.[a-zA-Z0-9_-]+$"
port:
type: integer
minimum: 1
maximum: 65535
default: 5432Guidelines:
- set
additionalProperties: falseand list every accepted field explicitly - give safe local-development
defaults where possible - keep secret-bearing fields separate from plain config (see Secrets below) and give them a
passwordFrom/tokenFrom-style name so their purpose is obvious from the schema alone - add a
descriptionto non-obvious properties; it is the primary documentation a consumer of the module sees, sinceREADME.mdis optional
A profile supplies concrete values for these fields, and cds init <profile> generates a project-root .env template from the resolved config across all of a profile's modules. See docs/installation.md for the end-to-end flow.
Modules should assume that profiles provide a shared Docker network.
Modules may:
- attach services to the shared profile network
- expose ports for local access
Modules should not:
- require undocumented external networks
- create isolated network behavior unless there is a strong reason
Modules that persist data should use named Docker volumes.
Examples:
- database data directories
- application metadata
- generated documentation
- local cache/state that should survive restarts
Avoid committing runtime-generated data to the repository unless it is intentionally part of an example.
Modules must not require committed secrets.
Secrets are declared in spec.configSchema as string fields matching the ^secrets\.[a-zA-Z0-9_-]+$ pattern (see passwordFrom, tokenFrom above). CDS resolves these references to ${CDS_VAR} placeholders using environment variables or local .env files excluded from version control (generated via cds init).
A profile can also compose a secrets module such as Vault. That module runs as an ordinary service and handles its own runtime integration; it is not a separate secret-loading backend used by the CDS renderer.
Do not give secret-bearing fields a default in configSchema, leaving them required with no default forces every consumer to supply a real value.
If a module depends on a secrets provider, that dependency must be documented in metadata.description and, if present, the module's README.md, and in any profile that uses it.
Modules with long-running services should define health checks where meaningful, typically guarded with enabledFrom/conditionallyEnabledFrom so they can be disabled per-profile.
Examples:
- database readiness checks
- HTTP health endpoints
- worker ping checks
- broker readiness checks
Health checks should reflect actual readiness, not just whether the process has started.
README.md is optional and, when present, is for human readers only; it is not read by the validator. The authoritative, machine-checked documentation of a module is module.yaml itself:
metadata.description— what the module doesspec.configSchemapropertydescriptions — what each config field meansspec.provides/spec.consumes— what contracts the module offers or needs
If you do add a README.md, follow the format guide in docs/module-readme-template.md. modules/secrets/vault/README.md is a completed example. Keep it to context that doesn't belong in YAML: rationale, known limitations, links to upstream docs.
Modules may depend on other modules, but dependencies must be explicit through spec.provides / spec.consumes contracts (see docs/architecture.md), not through hardcoded service names or hidden assumptions.
Examples:
- an orchestration module may depend on a database and a queue
- a BI module may depend on a metadata database and a broker
- a transformation module may depend on a warehouse module
Profiles are responsible for composing modules together. Modules should avoid hiding cross-module assumptions whenever possible.
Modules are reusable building blocks.
Profiles are runnable stack combinations built from modules.
Examples of profiles:
- Dagster + Postgres + KeyDB + Superset
- Dagster + Postgres + KeyDB + Superset + Vault
A profile is responsible for:
- selecting modules
- wiring them together
- defining shared environment and network behavior
- documenting startup order and operational flow
Use names based on role and implementation.
Examples:
- modules/orchestration/dagster
- modules/warehouse/postgres
- modules/bi/superset
- modules/secrets/vault
- modules/cache/keydb
- modules/identity/keycloak
Avoid vague names such as:
- db
- assets
A module is considered complete when:
- it contains a valid
module.yamlthat passescds validateagainstcli/resources/module.schema.json - its
spec.configSchemafully describes every accepted config field, withadditionalProperties: false - its dependencies are declared through
spec.provides/spec.consumes - it can be included in at least one profile
- its services start successfully in that profile
README.md is encouraged for anything not obvious from module.yaml, but is not required for a module to be considered complete.