Template Directory Rendering Tutorial#
This tutorial walks you through the recommended pattern for rendering a directory
tree of Go templates into output files while preserving the directory structure.
You’ll learn how to combine three providers — directory, go-template, and
file — in a single solution to scaffold entire projects from templates.
Prerequisites#
- scafctl installed and available in your PATH
- Basic familiarity with solution files and Go template syntax
Table of Contents#
- Overview
- Setting Up Templates
- Reading Templates with the Directory Provider
- Batch Rendering with render-tree
- Writing Files with write-tree
- Path Transformation with outputPath
- Putting It All Together
- Advanced Patterns
Overview#
A common scaffolding task is:
- You have a directory of template files (
.tpl,.tmpl,.gotmpl, etc.) - You want to render each template with shared variables
- You want to write the rendered output preserving the directory structure
scafctl solves this with three operations:
| Step | Provider | Operation | Purpose |
|---|---|---|---|
| 1 | directory | list | Read template files with their content |
| 2 | go-template | render-tree | Batch-render all templates at once |
| 3 | file | write-tree | Write rendered files preserving structure |
Setting Up Templates#
Create a templates/ directory with your Go template files:
templates/
├── README.md.tpl
├── config/
│ └── app.yaml.tpl
└── k8s/
├── deployment.yaml.tpl
└── service.yaml.tplEach .tpl file is a standard Go template. For example, k8s/deployment.yaml.tpl:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .appName }}
namespace: {{ .namespace }}
spec:
replicas: {{ .replicas }}
selector:
matchLabels:
app: {{ .appName }}
template:
spec:
containers:
- name: {{ .appName }}
image: {{ .registry }}/{{ .appName }}:{{ .appVersion }}
ports:
- containerPort: {{ .containerPort }}Reading Templates with the Directory Provider#
Use the directory provider with includeContent: true to read all templates recursively:
resolvers:
templateFiles:
type: any
resolve:
with:
- provider: directory
inputs:
operation: list
path: ./templates
recursive: true
filterGlob: "*.tpl"
includeContent: trueThis returns an object with an entries array. Each entry has:
path— the relative path (e.g.k8s/deployment.yaml.tpl)content— the file’s raw content (the unrendered template text)- Plus metadata like
name,size,extension, etc.
Batch Rendering with render-tree#
The go-template provider’s render-tree operation takes an array of
{path, content} entries and renders each content as a Go template:
resolvers:
rendered:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.templateFiles.entries'
data:
appName: myapp
namespace: production
replicas: 3
registry: ghcr.io/myorg
appVersion: "1.0.0"
containerPort: 8080The output is an array of {path, content} where each content is now the
rendered result. The path is passed through unchanged from the input.
Per-entry data (fan-out)#
Each entry may carry an optional data map that is shallow-merged over the
shared top-level data for that entry only. On key conflicts, per-entry values
win over shared data, iteration variables, and resolver context. This turns
the common “render one template per item, each with its own variables and its
own output path” pattern into a single resolver – no separate forEach render
resolver and no manual index-zip:
resolvers:
backendConfigs:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
data:
platformAppName: myapp # shared default (merge base)
entries:
expr: |
_.environments.map(env, {
"path": "envs/" + env.name + "/backend.tf",
"content": _.backendTemplate.entries[0].content,
"data": {"environment": env} # per-entry, wins over shared
})Entries without a data field render against the shared data alone, so
existing render-tree usage is unaffected. A non-map data value fails with a
clear error naming the entry index and path.
Tree fan-out (forEach + pathTemplate)#
Per-entry data fans out a hand-built entry list. To fan out the whole
template tree – rendering every entry once per item in a collection, the way
a Terraform module is instantiated once per for_each value – use forEach
together with pathTemplate. The template set stays defined once (here from the
directory provider) and each item’s copy is routed to a distinct subtree:
resolvers:
perEnvTree:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.templateFiles.entries' # the shared template tree
data:
platformAppName: myapp # shared across every copy
forEach:
item: env # alias for the current item
in:
rslvr: environments # array resolved by the spec layer
pathTemplate: >-
envs/{{ .env.name }}/{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ .__fileStem }}Given N template entries and M items, the result is one flat array of N x M
rendered entries – ready to hand straight to the file provider’s
write-tree. For each copy the current item is bound under the forEach.item
alias (env) and __item, its position under forEach.index (if set) and
__index, and pathTemplate is rendered with access to those plus the shared
data and the reserved __file* path parts (__fileDir, __fileStem, and so
on). Rendered paths must be unique across the fan-out – a duplicate is a hard
error, so a mis-written pathTemplate can never silently collapse the output.
Verbatim Files in a Mixed Tree (raw)#
Sometimes a tree mixes templated files with files that must be copied exactly
as-is – a fixture that is itself Go-template syntax, or a GitHub Actions
workflow full of ${{ ... }} that collides with Go template delimiters. Rather
than splitting into a second copy pipeline or wrapping the whole file in ignore
markers, mark those files raw:
resolvers:
rendered:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
rawGlobs:
- "**/*.raw" # copied verbatim; everything else is rendered
entries:
expr: '_.templateFiles.entries'
data:
rslvr: varsrawGlobs is a list of doublestar
patterns matched against each entry’s full relative path (so *.tf matches
only top-level files, **/*.tf also matches nested ones). A matching entry is
emitted byte-for-byte: no parsing, no delimiters, no ignoredBlocks, and any
per-entry data ignored. This mirrors Cookiecutter’s _copy_without_render.
When entries are hand-constructed in CEL, a per-entry raw boolean takes
precedence over rawGlobs – raw: true forces verbatim even without a match,
raw: false forces rendering even when a pattern matches:
entries:
expr: |
[
{"path": "keep.txt", "content": "{{ literal }}", "raw": true},
{"path": "render.raw", "content": "hi {{ .who }}", "raw": false}
]The missing-template-dependency lint rule skips raw-matched files, since they
are never parsed. See the runnable
template-mixed-tree
example.
Using a Separate Vars Resolver#
For cleaner solutions, define variables in their own resolver and reference them:
resolvers:
vars:
type: any
resolve:
with:
- provider: static
inputs:
value:
appName: myapp
namespace: production
# ... more vars
rendered:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.templateFiles.entries'
data:
rslvr: varsWriting Files with write-tree#
The file provider’s write-tree operation takes an array of {path, content}
entries and writes them under a basePath:
workflow:
actions:
write-output:
provider: file
inputs:
operation: write-tree
basePath: ./output
entries:
rslvr: renderedThis writes each entry as a file relative to basePath, automatically creating
subdirectories as needed. Without outputPath, files keep their original paths:
./output/k8s/deployment.yaml.tpl # Still has .tpl extension!
./output/k8s/service.yaml.tpl
./output/config/app.yaml.tpl
./output/README.md.tplTo strip the .tpl extension, use outputPath.
Path Transformation with outputPath#
The outputPath field is a Go template that transforms each entry’s path before
writing. It receives these variables:
| Variable | Description | Example for k8s/deployment.yaml.tpl |
|---|---|---|
__filePath | Original relative path | k8s/deployment.yaml.tpl |
__fileName | File name only | deployment.yaml.tpl |
__fileStem | File name without last extension | deployment.yaml |
__fileExtension | Last extension including dot | .tpl |
__fileDir | Directory part (empty for root files) | k8s |
Resolver values are also available in the template (for example {{ .environment }}),
so you can route files into dynamic, resolver-derived directories. The __file*
variables take precedence if a resolver has the same name.
Routing into a Resolver-Derived Directory#
The outputPath template renders against the solution’s top-level resolver
values (keyed by resolver name), so to use {{ .environment }} you need a
top-level environment resolver:
resolvers:
environment:
type: string
resolve:
with:
- provider: static
inputs:
value: prodThen reference it in outputPath:
outputPath: "{{ .environment }}/{{ .__fileDir }}/{{ .__fileStem }}"Result (with environment: prod): k8s/deployment.yaml.tpl -> prod/k8s/deployment.yaml
Stripping the .tpl Extension#
The most common pattern – reconstruct the path using the stem (which drops .tpl):
outputPath: >-
{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ .__fileStem }}Result: k8s/deployment.yaml.tpl → k8s/deployment.yaml
Flattening All Files into One Directory#
Ignore the directory structure, keeping only file names:
outputPath: "{{ .__fileName }}"Result: k8s/deployment.yaml.tpl → deployment.yaml.tpl
Using Sprig Functions#
All Sprig template functions are available:
outputPath: "{{ lower .__filePath }}"Result: SRC/MyFile.TXT → src/myfile.txt
Replacing Extensions#
Change .tpl to .generated.yaml:
outputPath: >-
{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ trimSuffix ".tpl" .__fileName }}.generated.yamlPutting It All Together#
Here is a complete solution that reads templates, renders them, and writes the output:
apiVersion: scafctl.io/v1
kind: Solution
metadata:
name: template-directory-rendering
version: 1.0.0
description: Render a directory of Go templates into output files
spec:
resolvers:
# Variables shared across all templates
vars:
type: any
resolve:
with:
- provider: static
inputs:
value:
appName: myapp
appVersion: "1.2.0"
namespace: production
environment: prod
replicas: 3
registry: ghcr.io/myorg
containerPort: 8080
servicePort: 80
serviceType: ClusterIP
logLevel: info
# Read all template files
templateFiles:
type: any
resolve:
with:
- provider: directory
inputs:
operation: list
path: ./templates
recursive: true
filterGlob: "*.tpl"
includeContent: true
# Render all templates with vars
rendered:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.templateFiles.entries'
data:
rslvr: vars
workflow:
actions:
# Write rendered results, stripping .tpl extension
write-output:
provider: file
inputs:
operation: write-tree
basePath: ./output
entries:
rslvr: rendered
outputPath: >-
{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ .__fileStem }}Run it:
scafctl run solution -f solution.yamlscafctl run solution -f solution.yamlInspect the output:
find ./output -type f
# output/README.md
# output/config/app.yaml
# output/k8s/deployment.yaml
# output/k8s/service.yaml
cat ./output/k8s/deployment.yaml
# apiVersion: apps/v1
# kind: Deployment
# metadata:
# name: myapp
# namespace: production
# ...Advanced Patterns#
Combining with CEL Filtering#
Use a cel transform to filter entries before rendering — for instance, skip
files larger than 10KB:
resolvers:
small-templates:
type: any
resolve:
with:
- provider: directory
inputs:
operation: list
path: ./templates
recursive: true
filterGlob: "*.tpl"
includeContent: true
transforms:
- provider: cel
inputs:
expression: >-
{"entries": data.entries.filter(e, e.size < 10000)}Multiple Template Sets#
Render different template directories with different variables by defining separate resolver chains:
resolvers:
frontendTemplates:
type: any
resolve:
with:
- provider: directory
inputs:
operation: list
path: ./templates/frontend
recursive: true
filterGlob: "*.tpl"
includeContent: true
backendTemplates:
type: any
resolve:
with:
- provider: directory
inputs:
operation: list
path: ./templates/backend
recursive: true
filterGlob: "*.tpl"
includeContent: true
renderedFrontend:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.frontendTemplates.entries'
data:
framework: react
# ...
renderedBackend:
type: any
resolve:
with:
- provider: go-template
inputs:
operation: render-tree
entries:
expr: '_.backendTemplates.entries'
data:
framework: gin
# ...Then write each set to a different output directory:
workflow:
actions:
write-frontend:
provider: file
inputs:
operation: write-tree
basePath: ./output/frontend
entries:
rslvr: renderedFrontend
outputPath: >-
{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ .__fileStem }}
write-backend:
provider: file
inputs:
operation: write-tree
basePath: ./output/backend
entries:
rslvr: renderedBackend
outputPath: >-
{{ if .__fileDir }}{{ .__fileDir }}/{{ end }}{{ .__fileStem }}Dry Run#
Use --dry-run to preview what would be written without touching the filesystem:
scafctl run solution -f solution.yaml --dry-runscafctl run solution -f solution.yaml --dry-runThe write-tree action will report all paths that would be written, including
paths transformed by outputPath, without creating any files.