Config composition
Real deployments rarely run a single pipeline file. The same connection, sink target, and transform chain are reused across dev / staging / prod, and across many similar pipelines. Config composition lets you factor those shared pieces out of each file and recombine them at load time — without copy-pasting or templating engines.
Three mechanisms, all resolved when the file is read (before any ${...}
interpolation runs):
| Mechanism | What it does |
|---|---|
extends: |
Inherit one or more base config files; the child deep-merges on top. |
profiles: |
Declare named overlays in the file; select one at run time with --profile NAME / FAUCET_PROFILE. |
!include path |
Substitute a YAML fragment at any node (YAML only). |
This walkthrough uses the files shipped under
cli/examples/compose/.
A worked dev / staging / prod setup
Section titled “A worked dev / staging / prod setup”The shared base
Section titled “The shared base”base.yaml holds everything common to every environment — the source
connection and a neutral default sink — plus a profiles: block of
per-environment overlays that each visibly override it:
version: 1name: composed-pipelinepipeline: source: type: file config: path: ./data/input.csv sink: type: file config: path: ./out/output.jsonl # neutral default — overridden per-env by the profiles below
# Named overlays selected at run time via --profile / FAUCET_PROFILE.# Each profile points the sink at an environment-specific file.profiles: dev: pipeline: sink: config: path: ./out/dev.jsonl prod: pipeline: sink: config: path: ./out/prod.jsonlA reusable fragment
Section titled “A reusable fragment”transforms.yaml is a bare YAML sequence — a transform chain you can pull
into any pipeline:
- type: flatten config: { separator: "__" }- type: keys_case config: { mode: snake }The pipeline that ties it together
Section titled “The pipeline that ties it together”app.yaml inherits the base and pulls in the transform chain with !include:
extends: ./base.yamlpipeline: transforms: !include ./transforms.yamlRun it against an environment by selecting a profile:
faucet run cli/examples/compose/app.yaml --profile prodThe composed pipeline reads ./data/input.csv (from the base), applies the
flatten → keys_case chain (from the include), and writes
./out/prod.jsonl (from the prod profile overlay). Without --profile,
the sink falls back to the neutral base default (./out/output.jsonl);
--profile dev redirects it to ./out/dev.jsonl.
extends — base inheritance
Section titled “extends — base inheritance”extends: names one or more base files. Relative paths resolve against the
directory of the file that declares them. The child document deep-merges on
top of the base (child keys win on collision).
# Single baseextends: ./base.yaml
# A list of bases — merged left-to-right, so later bases override earlier ones,# and the child document overrides them all.extends: - ./connection.yaml - ./sink-defaults.yamlBases may themselves extends: other files; the chain is followed to its root
(a depth cap and cycle detection guard against runaway or circular includes).
profiles + --profile / FAUCET_PROFILE
Section titled “profiles + --profile / FAUCET_PROFILE”A top-level profiles: block maps a name to a partial config that is
deep-merged over the composed document when that profile is selected.
Nothing is applied unless a profile is chosen:
faucet run app.yaml --profile prod # explicit flagFAUCET_PROFILE=prod faucet run app.yaml # via environmentThe flag overrides the environment variable. --profile prod with
FAUCET_PROFILE=dev set selects prod. Selecting a name that isn’t declared
is a clear load-time error (unknown profile '<name>').
profiles: and extends: compose freely: a base can declare the profiles and
the child can select one at run time, as in the worked example above.
!include — YAML fragment substitution
Section titled “!include — YAML fragment substitution”!include path (a YAML tag) replaces the node it tags with the parsed contents
of another YAML file. The fragment can be any YAML value — a sequence (as in
transforms.yaml), a mapping, or a scalar — and is substituted structurally
before the document is interpreted:
pipeline: transforms: !include ./transforms.yaml # a sequence fragment source: !include ./source.yaml # a mapping fragment!include is YAML-only — it is a YAML tag, so it has no equivalent in JSON
configs. Paths resolve against the including file’s directory, like extends:.
Precedence
Section titled “Precedence”Everything is merged with the same deep-merge rule used by matrix rows:
objects merge recursively, arrays replace wholesale, scalars replace. The
layers, from lowest to highest priority (last wins):
extended base(s) → child document → selected profile → matrix rowextends:bases are the foundation (a list merges left-to-right).- The child document (the file you ran) overrides its bases.
- The selected
profileoverlays the composed document. - At expand time, each
matrixrow deep-merges on top — so a row can still override a profile-supplied value.
Composition resolves before all ${...} interpolation. The full load order
is:
- Composition —
extends/!includeare stitched, then the selectedprofileis overlaid;extends:/profiles:metadata keys are stripped. - Interpolation —
${env:…}/${file:…}/${secret:…}, then${vars.X}and${sources.X}/${sinks.X}. - Secrets-manager directives —
${vault:…}etc. (the final load-time stage). - Expand —
matrixrows are deep-merged per invocation.
This ordering means a profile can supply a value that a later
${env:…}/${vars.X} reference is then resolved within, and that a base file
can carry ${...} tokens resolved only after the merge.
Inspecting the result: validate --show-composed
Section titled “Inspecting the result: validate --show-composed”faucet validate --show-composed prints the fully composed config — bases
merged, the selected profile applied, fragments substituted, and the
extends: / profiles: metadata stripped — before ${...} interpolation.
It’s the fastest way to confirm a multi-file setup resolves to what you expect:
faucet validate cli/examples/compose/app.yaml --show-composed --profile prodversion: 1name: composed-pipelinepipeline: source: type: file config: path: ./data/input.csv sink: type: file config: path: ./out/prod.jsonl # ← from the prod profile transforms: # ← from the !include - type: flatten config: separator: __ - type: keys_case config: mode: snakeSecurity
Section titled “Security”Composition is file-loads-only. extends, profiles, and !include are
resolved only when faucet reads a config from disk (run, validate,
preview, doctor, schedule). They are not honored for configs submitted
to faucet serve over HTTP — a submitted body is parsed as a single,
self-contained document with no filesystem access. This keeps a multi-tenant or
internet-exposed serve process from being coerced into reading arbitrary local
files via a crafted extends: / !include path. Compose your config locally and
submit the result (validate --show-composed gives you exactly that document).
See also
Section titled “See also”- Config reference — composition — concise field grammar for
extends:,profiles:, and!include. - Transforms — the full set of built-in record transforms you can include or layer per profile.
- Secrets — interpolate secrets-manager references that survive composition unchanged until the final load stage.
- State & resumability — bookmark-based incremental runs that compose cleanly with profile overlays.