Skip to content

Layered Configuration

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/guide/layered-configuration/.
Where this project builds one composite for several environments, write one `base` config and one object per environment that spreads it, with a nested `...base.<key>` for each nested object to merge, in a `config.ts` as the page shows.
Give each environment its own `name`, use no merge helper or function call (EVL004), and call the composite once per environment.
Run `npx chant build` and `npx chant lint`, check each environment's output, and open a pull request.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

To deploy one composite to several environments, with per-environment differences, layer its config. Presets cover one level of shared defaults spread into resources. Layered configuration is a base config that each environment overrides, nested objects included. It is plain TypeScript object spread in one file, with no merge helper or codegen.

Define one base, then layer each environment over it. Override only what differs; everything else inherits.

src/config.ts
// Layered configuration — one composite, many environments, no new mechanism.
//
// A base config is overridden per environment with plain TypeScript object
// spread. The nested `labels` object is deep-merged with native nested spread
// (`...base.labels`). Everything is static const-to-const data, so it resolves
// at synthesis — chant's evaluability rules (EVL) permit const spread and
// static member-access spread; only a `deepMerge()` *function call* or a
// computed `config[key]` access would be rejected.
//
// This is the whole of "layered config": presets are the single-level case;
// this is the multi-level generalization, in the language you already have.
import type { WebAppProps } from "@intentius/chant-lexicon-k8s";
// ── Layer 1: base — every environment starts here ──────────────────────────
// Note: no `name` — each environment supplies its own so resources stay
// distinct and collision-free in a single build.
const base = {
image: "ghcr.io/acme/web:1.4.0",
port: 8080,
replicas: 2,
cpuRequest: "100m",
cpuLimit: "250m",
memoryRequest: "128Mi",
memoryLimit: "256Mi",
// A base-only nested object — every environment inherits it unchanged, so it
// is set once here. (Not every nested layer needs a per-env override.)
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
readOnlyRootFilesystem: true,
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
},
// A nested object that DOES vary per environment — deep-merged below.
labels: {
"app.kubernetes.io/part-of": "acme-web",
"app.kubernetes.io/managed-by": "chant",
},
};
// ── Layer 2: per-environment overrides, spread over the base ───────────────
// `...base` shallow-merges the top level; `...base.labels` deep-merges the one
// nested object. Override only what differs; everything else inherits.
export const dev: WebAppProps = {
...base,
name: "web-dev",
// dev keeps base replicas/resources; no ingress.
labels: { ...base.labels, "acme.io/env": "dev" },
};
export const staging: WebAppProps = {
...base,
name: "web-staging",
replicas: 3,
cpuLimit: "500m",
memoryLimit: "512Mi",
ingressHost: "staging.acme.example",
minAvailable: 1,
labels: { ...base.labels, "acme.io/env": "staging" },
};
export const prod: WebAppProps = {
...base,
name: "web-prod",
replicas: 6,
cpuRequest: "250m",
cpuLimit: "1",
memoryRequest: "256Mi",
memoryLimit: "1Gi",
ingressHost: "acme.example",
minAvailable: 2,
// Deep-merge: keep the base labels, add prod-only ones.
labels: { ...base.labels, "acme.io/env": "prod", "acme.io/tier": "critical" },
};

Then instantiate the same composite once per environment. Each call passes a static (const) prop bag to the composite factory; the layering already happened in config.ts:

src/web.ts
// One composite, instantiated once per environment from layered config.
//
// Each call is the blessed composite factory call with a static (const) prop
// bag — the layering already happened in config.ts. Per-environment names
// (`web-dev`/`web-staging`/`web-prod`) keep the three stacks' resources
// distinct in a single build.
import { WebApp } from "@intentius/chant-lexicon-k8s";
import { dev, staging, prod } from "./config";
export const devApp = WebApp(dev);
export const stagingApp = WebApp(staging);
export const prodApp = WebApp(prod);

All three build into one manifest. Per-environment names (web-dev / web-staging / web-prod) keep their resources from colliding.

chant’s evaluability rules (EVL) require resource inputs to resolve to static data. Object spread from a const qualifies, at any depth:

  • { ...base, replicas: 6 } — const spread. Allowed (the same rule presets use).
  • { ...base.labels, "acme.io/env": "prod" } — static member-access spread. Allowed: reading a known property of a const is static. Only a computed access like config[key] is rejected (EVL003).
  • deepMerge(base, prod) — a function-call initializer. Rejected by EVL004. This is the one thing that does not work, and it is why the pattern uses native nested spread instead of a merge helper.

So the shallow level is ...base and each nested object you want to merge gets its own ...base.<key>. A nested object you don’t spread is replaced wholesale, the same rule presets follow. That is often what you want (securityContext in the example is set once on the base and inherited unchanged).

Give each environment its own name so resources don’t collide in a single build. In the example the base deliberately omits name, and each environment supplies its own (web-dev, web-staging, web-prod). Thread an env or name through the composite the same way for any multi-environment stack.

This pattern stays in plain TypeScript on purpose: every environment is an explicit object you can read and audit, and the build is deterministic. To see the effective config for one environment, run chant describe.

The full example is lexicons/k8s/examples/layered-config and CI builds it.

Terminal window
cd lexicons/k8s/examples/layered-config
npm install
npm run build # → k8s.yaml (three environments)
npm run lint # the gate — clean

chant