Skip to content

Multi-Stack Projects

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/multi-stack/.
Split or join this project's stacks as the page shows: publish each shared value with `output()` in the producing project and declare a matching parameter in the consuming one, with the same name.
Build each stack with its own `npx chant build <dir> -o <out>`, run `npx chant lint`, and open a pull request.
List the values each stack needs at deploy time in the pull request description.
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`.

When an estate outgrows one stack, use side-by-side projects: each stack is its own project, with its own chant.config.ts and its own flat src/. They sit next to each other and reference each other by name.

Each sibling is an ordinary single-stack project: chant build src -o dist/template.json, one output, deployed on its own, with nothing multi-stack-specific to learn.

Someone opening alb-infra/src/alb.ts reads an ALB and can predict what ships, because the unit they opened is the unit that deploys. A splitting convention inside one project would put a translation step between the two.

The gitlab-aws-alb-* examples are two siblings: shared infrastructure, and the services that run on it:

examples/
├── gitlab-aws-alb-infra/ ← VPC, ALB, ECS cluster, ECR
│ ├── chant.config.ts
│ └── src/
│ ├── network.ts
│ ├── alb.ts
│ ├── ecr.ts
│ └── outputs.ts ← what this stack publishes
└── gitlab-aws-alb-services/ ← both services on that ALB
├── chant.config.ts
└── src/
├── params.ts ← what this stack consumes
└── services.ts ← two FargateService calls

Every src/ is flat. Every project builds with the same plain command it would use alone.

The API and UI services are one project rather than two because they are one deploy unit: they share every ALB parameter, they are always deployed together, and neither is useful without the listener the other one also hangs off. A stack per service would have bought two independent deploy cadences the example never used, at the cost of a second copy of the same seven parameters. Split by what deploys together.

The producer publishes named handles with output():

gitlab-aws-alb-infra/src/outputs.ts
import { output } from "@intentius/chant-lexicon-aws";
import { network } from "./network";
import { shared } from "./alb";
// ALB outputs — service stacks reference these
export const clusterArn = output(shared.cluster.Arn, "ClusterArn");
export const listenerArn = output(shared.listener.ListenerArn, "ListenerArn");
export const vpcId = output(network.vpc.VpcId, "VpcId");

The consumer declares matching parameters:

gitlab-aws-alb-services/src/params.ts
import { Parameter } from "@intentius/chant-lexicon-aws";
// Shared ALB stack outputs — passed via --parameter-overrides at deploy time
export const clusterArn = new Parameter("String", { description: "ECS Cluster ARN" });
export const listenerArn = new Parameter("String", { description: "ALB Listener ARN" });
export const vpcId = new Parameter("String", { description: "VPC ID" });

The join key is the name. "ClusterArn" published by one stack is clusterArn consumed by another, and the value crosses at deploy time through the provider’s own mechanism, --parameter-overrides for CloudFormation.

Deploy order follows the reference: the producer first, then the consumers. Each project’s README states its dependency. Consumers read their inputs as ordinary declared parameters, with no chant-specific wiring or shared state.

Each project graphs on its own, and the IR carries both halves of the link:

  • exports — what this stack publishes: the output name and the node producing it.
  • imports — what it consumes: the parameter name and its node.

Matching an import’s name to another stack’s export name reconstructs the cross-stack edge. chant emits the handles, and a viewer pointed at several projects joins them. behold does this: point it at N project directories and it draws one boundary box per project with the cross-stack edges between them.

A project can also declare several stacks itself, each built from its own directory. Use this when the pieces belong to one project and you want them versioned and reviewed together. Side-by-side projects stay the default way to split an estate.

chant.config.ts
export default {
lexicons: ["aws"],
stacks: [
{ name: "networking", src: "src/networking" },
{ name: "compute", src: "src/compute" },
],
} satisfies ChantConfig;

Each entry names the deployed stack and the source built for it; region is available for an estate spread across several. Build each one with its own chant build <dir> -o <out>.

With stacks set, chant lifecycle snapshot and chant lifecycle diff iterate every stack, building each src scoped so its logical ids match what that stack deploys, and observing each against its own live stack name. Without it, chant assumes a single stack named after the environment. chant graph uses the same declaration: it qualifies node ids <stack>::<id> to line up with observation, and groups them under groups.byStack.

The cost is the one side-by-side projects avoid: a reader has to consult chant.config.ts to learn which directories are deploy units before they can predict anything.

A child project is a subdirectory that builds to a separately-valid template and is referenced explicitly from a parent file. The parent uses a lexicon-specific function like nestedStack(), passes parameters in, and reads outputs back, with cross-stack values declared via stackOutput().

Use it when you want one deployable unit that carries its children. For independent stacks, use side-by-side projects.

my-project/
├── src/
│ ├── app.ts ← parent resources + nestedStack() call
│ └── network/ ← child project
│ ├── vpc.ts
│ ├── security.ts
│ └── outputs.ts ← stackOutput() declarations
└── dist/
├── template.json ← parent (AWS::CloudFormation::Stack)
└── network.template.json ← child (standalone template)
src/network/outputs.ts
/**
* Cross-stack outputs — values the parent can reference
*/
import { stackOutput } from "@intentius/chant";
import { vpc, subnet } from "./vpc";
import { lambdaSg } from "./security";
export const vpcId = stackOutput(vpc.VpcId, {
description: "VPC ID",
});
export const subnetId = stackOutput(subnet.SubnetId, {
description: "Public subnet ID",
});
export const lambdaSgId = stackOutput(lambdaSg.GroupId, {
description: "Lambda security group ID",
});
src/app.ts
/**
* App layer — Lambda function in the parent template that references
* the network nested stack's outputs via cross-stack references
*/
import { Function, Sub, AWS, Ref, nestedStack } from "@intentius/chant-lexicon-aws";
// nestedStack() references a child project directory
const network = nestedStack("network", import.meta.dirname + "/network", {
parameters: { Environment: "prod" },
});
export const handler = new Function({
FunctionName: Sub`${AWS.StackName}-handler`,
Runtime: "nodejs20.x",
Handler: "index.handler",
Role: Ref("LambdaExecutionRole"),
Code: { ZipFile: "exports.handler = async () => ({ statusCode: 200 });" },
VpcConfig: {
SubnetIds: [network.outputs.subnetId],
SecurityGroupIds: [network.outputs.lambdaSgId],
},
});
// Re-export so discovery picks it up as an entity
export { network };

The child can be built independently (chant build src/network/), and the parent build produces multiple template files. See your lexicon’s documentation, such as the AWS Nested Stacks guide.

Earlier versions of this guide described a mode where a single chant build src detected subdirectories and emitted one stack per directory automatically, plus a manifest.json.

chant build does not do that. It partitions by lexicon (a project declaring aws and k8s produces one output per lexicon) and writes to the single path you give it with -o. Subdirectories carry no meaning to it.

If you have a project written against the old description, nothing has changed underneath you: it was never auto-partitioning. Give each stack its own project, or its own stacks[] entry and build command.

See Multi-Stack Output for the serialization-level detail.

chant