Argo CD app-of-apps
Argo CD’s app-of-apps pattern uses one root Application that syncs a directory of child Application resources, one per deployable unit. @konfig.ts/argocd produces that layout for you: each konfig.ts module becomes one Argo CD Application, and the build writes a directory of Application CRs for the root app to sync. This guide walks from defining a single Application to the directory Argo CD needs.
The pieces
The workflow has three parts. You define one Application per module, you compose the modules into an app-of-apps, and the type checker confirms that every dependency between them is satisfied before anything is rendered. Four exports cover this.
Application.define(opts)builds one Application. You give it a name, a namespace, where its manifests live in git, and a build function that produces the manifests. The name and namespace must be string literals rather than values typed asstring; that restriction is what lets the dependency graph reason about names at the type level.Application.targetis what you pass as the target when you wrap a reusable module withModule.fixedNsorModule.dynamicNs. It makes the git source a per-call argument and lets the module set project, sync policy, and annotations.Application.make(opts)builds the plain Application value without any dependency tracking.Application.definecalls it once the build function has produced the manifests.AppOfApps.fromModules({ target, defaults, modules, provides?, name? })composes the modules, resolves every dependency between them, and is the compile-time gate: it compiles only when no dependency is left unsatisfied, and returns a sealed, directly renderable program. Aprovides:layer satisfies group-level needs that no listed module covers. The result carries the composed name, target, defaults, and the list of Applications. The name defaults to"apps"and becomes the directory of Application CRs. See Dependency graph.
The options of Application.define:
| Option | Meaning |
|---|---|
name, namespace | Literal strings identifying the Application |
source | Where Argo CD finds this Application’s manifests in git |
build | Function producing the manifests |
provides (optional) | What this module provides to other modules in the dependency graph |
project, syncPolicy, annotations (optional) | Copied into the child Application CR |
Two shapes: target and source
Two locations matter. The target says where the whole rendered environment lives in git; it is used once, to build the child Application CRs:
interface AppOfAppsTarget { readonly repoURL: string readonly branch: string readonly rootPath: string // directory Argo's root app syncs readonly controllerNamespace?: string // metadata.namespace of the CRs, default "argocd"}The source is set per module and becomes the spec.source of that module’s Application, so Argo CD knows which directory to sync for it:
interface ArgoSource { readonly repoURL: string readonly targetRevision: string readonly path: string}In practice every module’s source is a subdirectory of the target. The example derives it with a small helper so each child points at <rootPath>/<module name>. Here is the whole env file of the production environment.
const branch = "main"
const rootPath = "./infra/k8s/manifests/prod"
const src = (name: string) => ({
repoURL: cluster.repositoryUrl,
targetRevision: branch,
path: `${rootPath}/${name}`
})
const sopsBase = "infra/secrets"
const sopsOperator = defineSopsOperator({
name: "sops-secrets-operator",
source: src("sops-operator")
})
const imagePulls = defineImagePulls({
name: "image-pulls",
source: src("image-pulls"),
sopsBase
})
const featureFlags = defineFeatureFlags({
name: "feature-flags",
source: src("feature-flags")
})
const postgres = definePostgres({
name: "postgres",
source: src("postgres"),
storageGi: 20
})
const apiBuild = defineApiBuild({
name: "api-build",
source: src("api-build"),
registry: "ghcr.io/example",
tag: "1.0.0"
})
const workerBuild = defineWorkerBuild({
name: "worker-build",
source: src("worker-build"),
registry: "ghcr.io/example",
tag: "1.0.0"
})
const api = defineApi({
name: "api",
source: src("api"),
replicas: 2,
sopsBase
})
const worker = defineWorker({
name: "worker",
source: src("worker"),
replicas: 1,
sopsBase
})
const redisCache = defineRedisCache({ name: "redis-cache", source: src("redis-cache") })
export default AppOfApps.fromModules({
target: { repoURL: cluster.repositoryUrl, branch, rootPath },
defaults: { destination: { server: "https://kubernetes.default.svc" } },
modules: [
sopsOperator,
imagePulls,
featureFlags,
postgres,
apiBuild,
workerBuild,
redisCache,
api,
worker
]
})The repository URL comes from a plain TypeScript constant shared by every environment.
export const cluster = {
domain: "example.dev",
repositoryUrl: "ssh://git@github.com/example/full-stack.git"
} as constSync order with Sync.*
Argo CD decides the order in which it applies resources from argocd.argoproj.io/* annotations. Sync produces those annotation records so you do not type the keys by hand. You pass the record as, or spread it into, the annotations of Application.define, of Module.fixedNs, or of a resource’s metadata.
Sync.wave(n)sets the sync wave. Lower waves are applied first.Sync.hook(phase)marks a resource as a hook. The phase is one ofPreSync,Sync,PostSync,SyncFail,PostDelete.Sync.options(["Prune=false", ...])sets sync options, comma-joined into the annotation.
The example places the sops operator at wave -2, secrets and infrastructure at -1, and workloads at the default 0, so the operator and its CRD exist before any encrypted secret is applied.
export const defineSopsOperator = Application.module({
namespace: "sops",
annotations: Sync.wave(-2),
build: ({ namespace }, opts: Record<never, never>) => {
const ns = Namespace.make({ name: namespace })
const release = Helm.release({
repo: "https://isindir.github.io/sops-secrets-operator/",
chart: "sops-secrets-operator",
version: "0.19.0",
digest: "sha256:e2a1cd7ef2c6fd53aad8fa49a1080d425c3648177a87fc20d5f9f6133cbb8e54",
namespace,
extraOpts: ["--include-crds"],
values: {
secretsAsFiles: [
{ name: "age-key", mountPath: "/etc/sops-age", secretName: "sops-age" }
],
extraEnv: [{ name: "SOPS_AGE_KEY_FILE", value: "/etc/sops-age/age.key" }]
}
})
return [ns, release]
}
})Defaults and per-app overrides
Settings shared by every child Application go into defaults, so a module only states what differs. Three fields are covered:
| Field | Fallback when unset |
|---|---|
destination.server | https://kubernetes.default.svc |
project | "default" |
syncPolicy | none |
A module can override any of them. For the project, the module’s own value wins outright. For the sync policy the merge is more careful:
- If only one side sets it, that one is used.
- If both set it, the automated and retry sections (including retry backoff) are merged field by field, with the module’s values winning.
- The sync options list is not merged. When the module has a list, it replaces the default list whole.
The controller namespace from the target sets metadata.namespace of every CR and defaults to argocd.
What gets written
A build writes two kinds of output. Each Application’s manifests go into <root>/<outDir.manifests>/<env>/<app name>/. One Application CR per module goes into <root>/<outDir.manifests>/<env>/<result name>/, which is apps/ unless you named the app-of-apps. The whole environment directory is replaced on every build, so removing a module from the list removes its files from the output.
Each CR file is named Application-<name>.yaml, with . and / in the name replaced by -. If you generate CRs yourself, applicationCRFilename gives that name, serializeApplicationCR gives the YAML, and emitApplicationCR wraps the same YAML in a manifest you can compose further.
For the example above the tree looks like this:
.generated/manifests/prod/ apps/ Application-api.yaml Application-postgres.yaml ... api/ Deployment-api.yaml Service-api.yaml ... postgres/ ...Pointing Argo CD at it
-
Render and commit the output (with
outDir.manifestsset toinfra/k8s/manifestsso it matchesrootPath).Terminal window konfig build prodgit add infra/k8s/manifests/prod && git commit -m "render prod" -
Create one root Application by hand. Its
spec.source.pathis<rootPath>/<result name>, for exampleinfra/k8s/manifests/prod/apps, on the target’s branch. -
Sync the root app. Argo CD creates the child Applications from the CRs in that directory; each child syncs its own directory in sync-wave order.
Because every child CR is generated from the same target, changing the branch or root path in one place moves the whole environment. Removing a module removes its CR on the next build, and with automated.prune in the root Application’s sync policy Argo CD deletes the child.
Next: Image promotion shows how the apiBuild and workerBuild modules above get their tags in CI.