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.
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.