Skip to main content

atmos scaffold generate

Generate a component, configuration, or project shape from a template. The template's versioned manifest owns its fields, conditions, files, hooks, and update provenance.

atmos scaffold generate --help
 

Usage​

atmos scaffold generate [template] [target] [flags]

Examples​

# Prompt for active fields and generate into a new directory.
atmos scaffold generate terraform-component ./components/terraform/vpc

# Supply values for automation; defaults satisfy required fields.
atmos scaffold generate terraform-component ./components/terraform/vpc \
--defaults \
--set component_name=vpc \
--set environments=dev,staging

# Preview a template without writing files or running generation hooks.
atmos scaffold generate terraform-component ./preview --dry-run --skip-hooks

# Bring a recorded project forward after its template changes.
atmos scaffold generate terraform-component ./components/terraform/vpc \
--update --merge-strategy=manual

# Force line-oriented text merging so YAML formatting (e.g. blank lines) survives an update.
atmos scaffold generate terraform-component ./components/terraform/vpc \
--update --merge-driver=text

# Update without relying on the target's own Git history for the merge base.
atmos scaffold generate terraform-component ./components/terraform/vpc \
--update --update-strategy=rendered

Template Sources​

Select an embedded template, a template declared under scaffold.templates in atmos.yaml, or a local/remote source — git, HTTPS, S3, or an OCI registry reference. A git source can be pinned with --ref to make a release, tag, or commit explicit; --ref has no effect on OCI/S3/local sources, which address a specific version through the source string itself.

atmos scaffold list
atmos scaffold generate ./scaffolds/terraform-component ./components/terraform/vpc
atmos scaffold generate https://github.com/example/platform-templates.git ./output --ref v1.2.0
atmos scaffold generate oci://ghcr.io/example/templates:v1.0.0 ./components/terraform/vpc

An OCI source is pulled the same way atmos vendor pull fetches OCI-hosted components — authentication uses the same precedence (Docker keychain, then ATMOS_GITHUB_TOKEN for ghcr.io, then anonymous); see Vendor URL Syntax for details.

Template Configuration​

Use the versioned manifest below; the former top-level prompts: key is not valid.

scaffold.yaml
apiVersion: atmos/v1
kind: AtmosScaffoldConfig
metadata:
name: terraform-component
spec:
fields:
- name: component_name
label: Component name
type: input
required: true
validation:
pattern: "^[a-z0-9-]+$"
- name: create_monitoring
type: confirm
default: false
- name: alert_email
type: input
when: "answers.create_monitoring == true"
files:
- path: monitoring.tf
when: "answers.create_monitoring == true"

Fields render into template content and paths through {{ .Config.<field> }}. Required, option, boolean, and regular-expression validation is enforced after all answer sources merge: interactive answers, defaults, saved spec.values, and --set. select and multiselect require values from their declared options; false remains a valid answer for a required boolean.

when: accepts predicate words, CEL, or an implicit-all list. Conditions can inspect only earlier field answers through answers; use CEL (&&, ||, !) for compound logic because the map-style {all, any, not} form is not accepted by scaffold manifests.

Computed Fields​

A type: computed field is never prompted for and can't be set with --set — its value: is either a Go-template expression deriving it from other fields' answers (using the same answers.* binding options:'s dynamic form uses below), or a literal of any type (string, number, boolean, list, or map), used as-is with no rendering at all. A string is only treated as an expression when it actually contains a template action ({{ ... }}, or your configured delimiters); a plain string with none, like value: hello, is a literal too:

scaffold.yaml
spec:
fields:
- name: regions
type: multiselect
options: [us-east-1, us-west-2, eu-west-1]
- name: primary_region_select
type: select
options: answers.regions
when: "size(answers.regions) > 1"
- name: primary_region
type: computed
value: "{{ ternary answers.primary_region_select (index answers.regions 0) (gt (len answers.regions) 1) }}"
- name: provider_version_pins
type: computed
value:
aws: "~> 5.0"
azurerm: "~> 3.0"
google: "~> 5.0"

Without a computed field, every file that needs the "primary region, defaulting to the only region when there's just one" value has to re-derive it with the same {{ if .Config.primary_region_select }}...{{ end }} snippet. primary_region derives it once, and .Config.primary_region is then usable anywhere .Config is: file content, target:, and matrix: axes. provider_version_pins is a plain literal — no template rendering happens for it at all, it's just stored and exposed at .Config.provider_version_pins as-is, useful for a small hand-authored reference table. options: is the one exception — it's resolved before computed fields have a value, so a select/multiselect field's options: referencing a computed field is a load-time error rather than a silently non-functional constraint.

value: is required on a computed field (and rejected on every other field type); required: and default: are both rejected on a computed field, since it's always self-supplied. Computed fields evaluate once, in spec.fields[] declaration order, after every regular field's answer is already final — a computed field can reference any regular field regardless of order, but only an earlier-declared computed field's own result. Referencing itself or a later-declared computed field fails at scaffold-load time rather than silently rendering as <no value>. Because that evaluation happens after the interactive form completes, a regular field's own when: can never depend on a computed field — only the reverse works.

A computed field's value: must be pure — deterministic given the same answers, with no reliance on outside state that can change between calls (the current time, an environment variable, a remote fetch that isn't guaranteed to return the same content twice). Interactive generation with no target directory given evaluates every computed field twice: once to suggest a target directory name, again against the final answers to actually generate files. An impure expression can compute a different value each time, so the suggested directory and the generated files can end up reflecting two different results for the same field.

Dynamic and Label/Value Options​

select and multiselect fields declare their choices through options:, which accepts four shapes. A plain list of strings is the common case, where each choice's label and underlying value are the same:

scaffold.yaml
spec:
fields:
- name: environment
type: select
options: [dev, staging, production]

An option can instead be a {label, value} object, for when the displayed choice should read differently from the value stored in answers and passed to templates. value is required; label defaults to value when omitted:

scaffold.yaml
spec:
fields:
- name: environment
type: select
options:
- label: Development
value: dev
- label: Staging
value: staging
- value: production # No label -- displays as "production".

options: can also be sourced dynamically, using exactly the two forms spec.files[].matrix axes support below: a dot-path into answers.*, or a Go-template expression:

scaffold.yaml
spec:
fields:
- name: environments
type: multiselect
options: [dev, staging, production]

# Sourced from the prior multiselect answer -- only offers environments
# the user actually selected above.
- name: default_environment
type: select
options: "answers.environments"
scaffold.yaml
spec:
fields:
- name: csv_owners
type: input
label: Comma-separated list of component owners (e.g. GitHub teams)

- name: primary_owner
type: select
options: '{{ splitList "," answers.csv_owners }}'

A dot-path source must resolve to an already list-shaped answer -- a multiselect answer, a spec.values preset, or a --set-supplied value never declared as a field at all -- while a template expression computes the list the same way a matrix axis expression does. Both dynamic forms resolve correctly against whatever the earlier field was ultimately answered, whether that answer came from the interactive prompt (fields are prompted one at a time, so a later field is only ever shown after the ones before it) or from --set/--defaults.

When a later field's dot-path sources directly from a field using {label, value} options, labels are recovered for the filtered subset of values present in the referenced answer:

scaffold.yaml
spec:
fields:
- name: environments
type: multiselect
options:
- label: Development
value: dev
- label: Staging
value: staging
- label: Production
value: prod

# environments answer is [dev, staging] -> default_environment offers
# "Development" and "Staging" -- not "Production", and not the raw
# "dev"/"staging" values.
- name: default_environment
type: select
options: "answers.environments"

Only values ever reach answers and templates; labels are presentation-only.

Two limitations to keep in mind: there's no field-declaration-order validation at load time, so a forward reference, self-reference, or typo'd dot-path loads successfully and degrades to an empty option list (no constraint, any value accepted) at runtime instead of erroring; and label recovery looks back only one hop -- it does not chase labels through a chain of dynamic references, nor through the template-expression form, both of which fall back to label == value.

Loading External Data with !include and Other YAML Functions​

The !include YAML function, already available in stack manifests, also works in scaffold.yaml -- resolving before schema validation, so it can be used anywhere a literal YAML value is otherwise accepted: options:, a computed field's value:, and a matrix: axis.

A YQ-filtered !include shapes external data into {label, value} options directly, so a list doesn't have to be duplicated across fields that need the same choices:

scaffold.yaml
spec:
fields:
- name: license
type: select
options: !include "./lib/licenses.yaml '. | to_entries | map({\"label\": .value.full_name, \"value\": .key})'"

An unfiltered !include on a type: computed field's value: lands the raw included structure at .Config.<name> instead -- see Computed Fields above -- useful for a small reference table read as data rather than a list of choices:

scaffold.yaml
spec:
fields:
- name: license_lookup
type: computed
value: !include ./lib/licenses.yaml

lib/licenses.yaml here is a normal template file, resolved relative to scaffold.yaml's own directory. A local file that exists solely to be included this way is automatically excluded from generation output -- it's never copied into the generated project, the same way scaffold.yaml itself never is. !include also accepts everything it does in a stack manifest: a remote git::, oci://, or https:// source works the same way, with no such exclusion needed since a remote source is never part of the local template directory to begin with.

Included content is not processed transitively: a literal !include tag written inside an included file's own content is never resolved, and stays as inert, unprocessed text. This matches stack manifests, which share the same underlying !include implementation and the same limitation.

Besides !include/!include.raw, scaffold.yaml resolves a fixed set of other YAML functions that need no stack, component, or backend context -- !env, !random, !cwd, the !git.*/!repo-root family (!git.root, !git.sha, !git.ref, !git.branch, !git.repository, !git.owner, !git.name, !git.host, !git.url), and !literal (useful for a field value that looks like a Go template expression but should be taken as-is). Any other YAML function -- one that needs real stack, component, or backend context Atmos doesn't have while loading scaffold.yaml, such as !terraform.state, !store, or !secret -- is rejected with a clear error naming the tag, rather than silently left unresolved.

!exec is deliberately not supported, even though it needs no stack context: a template's scaffold.yaml is resolved for every template configured in atmos.yaml just to populate atmos scaffold list and the interactive template picker, not only the one a user actually selects or generates. Allowing shell execution here would let any configured template -- including a shared or vendored one -- run arbitrary code merely by being listed.

Dynamic File Generation​

matrix: expands a single discovered file into one generated file per resolved combination — the Cartesian product of one or more axes, using the same axis shape the workflow matrix: step uses. Axis values share the same answers.-prefix dot-path and template-expression convention as a dynamic options: source (see "Dynamic and Label/Value Options" above).

scaffold.yaml
spec:
fields:
- name: environments
type: multiselect
options: [dev, staging, production]
files:
- path: environment.yaml
target: "stacks/{{ .matrix.environment }}.yaml"
matrix:
environment: answers.environments

An axis's value is a literal list declared directly in scaffold.yaml (e.g. region: [us-east-1, us-west-2]), a dot-path into answers.* referencing an already list-shaped answer, such as a multiselect field, or a Go-template expression (any string containing {{) that computes the list. --set values for a multiselect field are split on commas automatically, so --set environments=dev,staging works non-interactively.

Declaring more than one axis expands their full Cartesian product; add when: to prune combinations that don't apply, using the matrix CEL variable alongside answers:

scaffold.yaml
spec:
files:
- path: deploy.yaml
target: "deploy/{{ .matrix.environment }}/{{ .matrix.region }}.yaml"
matrix:
environment: [dev, staging, production]
region: [us-east-1, us-west-2]
when: "matrix.region in answers.environments[matrix.environment].regions"

target: is required whenever matrix: is set. The resolved combination is available in target: and the file's own content — not just the output path — as .matrix.<axis>, matching Go template's leading-dot field access. when: is CEL, not Go template, so it reads the same value as matrix.<axis> instead, without the leading dot (see the when: example above). Two files (matrixed or not) rendering to the same output path is a hard error, never a silent overwrite.

An axis doesn't need to come from a multiselect at all — a plain free-text answer is just a string, and any Sprig/Gomplate function can split it into a list:

scaffold.yaml
spec:
fields:
- name: environments_csv
type: input
label: Comma-separated list of environments
files:
- path: deploy.yaml
target: "deploy/{{ .matrix.environment }}.yaml"
matrix:
environment: '{{ splitList "," answers.environments_csv }}'

Typing dev,staging,production at the prompt generates the same three files a multiselect with those three options would — except the values aren't limited to a fixed, template-author-declared list.

When an axis's values aren't already list-shaped anywhere in answers — e.g. an answer is itself a map of structured values rather than a flat multiselect — compute the list with collectKeys, a template function unconditionally available to axis expressions, alongside every Sprig/Gomplate function. Scaffold templating always has both available and is independent of the templates.settings.sprig.enabled/templates.settings.gomplate.enabled settings, which only gate stack manifest templating. collectKeys(m) returns m's top-level keys, sorted; collectKeys(m, "nestedKey") collects nestedKey's own keys from every value in m, flattened and deduplicated:

scaffold.yaml
spec:
files:
- path: deploy.yaml
target: "deploy/{{ .matrix.environment }}/{{ .matrix.region }}.yaml"
matrix:
environment: '{{ collectKeys answers.environments }}'
region: '{{ collectKeys answers.environments "regions" }}'
when: "matrix.region in answers.environments[matrix.environment].regions"

Given an environments answer shaped like this:

environments:
dev:
regions:
us-east-1: {}
production:
regions:
us-east-1: {}
us-west-2: {}

environment resolves to dev and production, and region to every region used by any environment (us-east-1 and us-west-2) — when: then prunes the Cartesian product down to each environment's actual regions.

Glob Paths and Directory-Level Matrix​

spec.files[].path may be a glob pattern instead of a literal path — *, ?, [...], ** (any depth, including zero), and {a,b} (brace expansion), matched against every discovered file's path. This lets one entry gate or multiply an entire directory at once, instead of listing every file individually. A backslash in the pattern is always treated as a forward-slash directory separator, regardless of which OS authored or evaluates it — discovered paths are always forward-slash-normalized. A malformed pattern (an unclosed [ or {) fails atmos scaffold validate and scaffold generation immediately, rather than silently and permanently matching nothing.

Skip (or gate) an entire directory, recursively, with one when:-gated entry:

scaffold.yaml
spec:
files:
- path: "docs/legacy/**"
when: "answers.include_legacy_docs"

When more than one entry's path: matches the same discovered file, the last matching entry in declaration order wins — the same precedence convention .gitignore/CODEOWNERS use. Order entries broad-to-specific, and place a more specific override after a broad glob it should shadow:

scaffold.yaml
spec:
files:
- path: "docs/legacy/**"
when: "answers.include_legacy_docs"
- path: "docs/legacy/keep-this.md"
when: "always"

Combine a glob path: with matrix: and target: to duplicate an entire directory's files once per matrix combination, exactly like a single-file matrix entry — every matched file gets the same .matrix.<axis> values a single-file entry would (resolved once per path:, not once per matched file, so every matched file sees the identical combination even if an axis expression uses a non-deterministic function). Since a glob can match many files, target: must differentiate which matched file an output came from, using two new template variables available everywhere .matrix.<axis> is (target: and the file's own content), regardless of whether path: is a glob or matrix: is even set:

  • .file.Path — the currently matched file's own discovered path.
  • .file.RelPath — .file.Path with the matching entry's glob literal prefix stripped. Equal to .file.Path when the entry's path: has no glob metacharacter.
scaffold.yaml
spec:
files:
- path: "components/**"
target: "environments/{{ .matrix.env }}/{{ .file.RelPath }}"
matrix:
env: [dev, staging, production]

A components/ directory containing vpc/main.tf and eks/main.tf produces six generated files: environments/dev/vpc/main.tf, environments/dev/eks/main.tf, and the same pair under staging/ and production/. A target: that doesn't differentiate matched files (e.g. omitting .file.RelPath) fails immediately, before any file in the run is written — this is checked deterministically, not just caught after a collision happens mid-run.

.file.Path/.file.RelPath are available in Go templates only — they aren't exposed to CEL when: alongside answers/matrix. To exclude one specific file from a glob+matrix entry's own when:, declare a second, more specific entry after the broad one instead, per the precedence rule above.

Generation Hooks​

Generation hooks run after answers are validated and before or after files are written:

scaffold.yaml
spec:
hooks:
prepare:
events: [before.scaffold.generate]
kind: step
type: shell
with:
command: mkdir -p generated
validate:
events: [after.scaffold.generate]
kind: steps
with:
- type: shell
command: terraform fmt -recursive
- type: shell
command: terraform validate

Scaffold hooks run in stable name order and support only kind: step and kind: steps. A single step hook uses its envelope type: plus step-specific with: data; a steps hook executes the ordered with: list. The shared envelope supplies events, when, env, retry, and on_failure. Use answers in hook CEL and {{ .Answers.<field> }} inside a step template.

An unset or bare-relative working_directory: on a step defaults to (or resolves under) the scaffold's target directory -- also available as {{ .TargetPath }} -- so terraform fmt -recursive above formats the generated project, not wherever atmos happened to be launched from. Use working_directory: "." to opt back into the directory atmos was launched from. A type: atmos step is exempt from this default: it keeps running in the directory atmos was launched from when working_directory: is unset, since the nested atmos invocation must resolve its own config there, but an explicit working_directory: on that step is still honored.

Use --skip-hooks to skip all hooks or --skip-hooks=prepare,validate to skip named hooks. The stack-level hooks reference documents additional stack-only kinds such as scanners, stores, Git, and CI integrations.

Update and Safety Flags​

--defaults
Use defaults and --set values without prompting.
--dry-run
Render a preview without generated-file writes.
--force
Permit generation into a non-empty target without update merging.
--update
Apply an optimistic three-way merge using the recorded source/base revision.
--base-ref

Override the recorded merge base (used with --update; defaults to HEAD). Only applies to --update-strategy=tracked — rendered's base comes from the target's own recorded .atmos/scaffold.yaml, not --base-ref. Combining --base-ref with --update-strategy=rendered is rejected; drop --base-ref when using rendered.

--update-strategy (default tracked)

Choose where --update's three-way merge base comes from. tracked reads it from the target's own Git history at --base-ref. rendered instead re-renders the template at the ref that produced what's currently on disk, using that generation's recorded answers, with no dependency on the target being a Git repository at all. This needs the template itself to define a scaffold.yaml manifest (so the old ref's fields can be resolved) and the target to already carry a prior generation's .atmos/scaffold.yaml record (so the original answers are recoverable) — two separate files, not one. Under rendered, --update also deletes a file the template stopped generating between refs, unless you've edited it locally — in that case the update fails with an unresolved conflict instead of silently deleting or keeping it. tracked doesn't support this: there's no safe way to know which files a historical commit actually belonged to the template.

--merge-driver (default auto)

Choose auto (YAML-aware for .yaml/.yml, text otherwise) or text to force every file through the line-oriented text merge driver, preserving formatting (e.g. blank lines) that a YAML-aware re-encode would otherwise collapse.

--merge-strategy (default manual)
Choose manual, ours, or theirs for merge conflicts.
--max-changes (default 50)

Maximum percentage of changed lines allowed in a --update three-way merge before it fails instead of applying. 0 disables this check entirely — the merge is never rejected for having too many changes (conflicts still write markers for --merge-strategy=manual to resolve). Any other value is compared against a computed change percentage that has no upper bound, so no positive value is a guaranteed bypass the way 0 is — raising it only makes a hard failure less likely, not impossible. Configurable via ATMOS_SCAFFOLD_MAX_CHANGES.

--recreate-deleted (default false)

By default, --update leaves in place a file you deleted that the template still generates, instead of silently recreating it. Pass --recreate-deleted to always recreate it with the template's current content. This is independent of --force/--merge-strategy: --force already means "on conflict, the template's version wins," so tying recreation to it would make manual conflict resolution and recreating a deleted file mutually exclusive.

--skip-hooks
Skip all hooks or a comma-separated set of hook names.
--git / --no-git
Control initial Git setup; generation defaults to no Git initialization.