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.
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.
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:
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:
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:
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:
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"
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:
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:
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:
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).
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:
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:
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:
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:
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:
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.Pathwith the matching entry's glob literal prefix stripped. Equal to.file.Pathwhen the entry'spath:has no glob metacharacter.
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:
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
--setvalues 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-refOverride the recorded merge base (used with
--update; defaults toHEAD). Only applies to--update-strategy=tracked—rendered's base comes from the target's own recorded.atmos/scaffold.yaml, not--base-ref. Combining--base-refwith--update-strategy=renderedis rejected; drop--base-refwhen usingrendered.--update-strategy(defaulttracked)Choose where
--update's three-way merge base comes from.trackedreads it from the target's own Git history at--base-ref.renderedinstead 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 ascaffold.yamlmanifest (so the old ref's fields can be resolved) and the target to already carry a prior generation's.atmos/scaffold.yamlrecord (so the original answers are recoverable) — two separate files, not one. Underrendered,--updatealso 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.trackeddoesn't support this: there's no safe way to know which files a historical commit actually belonged to the template.--merge-driver(defaultauto)Choose
auto(YAML-aware for.yaml/.yml, text otherwise) ortextto 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(defaultmanual)- Choose
manual,ours, ortheirsfor merge conflicts. --max-changes(default50)Maximum percentage of changed lines allowed in a
--updatethree-way merge before it fails instead of applying.0disables this check entirely — the merge is never rejected for having too many changes (conflicts still write markers for--merge-strategy=manualto 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 way0is — raising it only makes a hard failure less likely, not impossible. Configurable viaATMOS_SCAFFOLD_MAX_CHANGES.--recreate-deleted(defaultfalse)By default,
--updateleaves in place a file you deleted that the template still generates, instead of silently recreating it. Pass--recreate-deletedto always recreate it with the template's current content. This is independent of--force/--merge-strategy:--forcealready 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.