Template rendering
.tmpl files
A file named README.md.tmpl is rendered as README.md. The resolved param values are passed to the template engine as a map.
# {{ .project_name | title }}
{{ .description }}
- License: {{ .license }}
- Port: {{ .port }}
- Features: {{ join .features ", " }}
Template language basics
_base/*.tmpl files use Go's text/template. Here is what you can write:
Conditions
Licensed under MIT Licensed under Apache 2.0 No license file generated Proprietary (no license file)
License values in templates can be:
- A SPDX identifier:
MIT,Apache-2.0,BSD-3-Clause,BSD-2-Clause,ISC,0BSD,Unlicense,CC0-1.0,MPL-2.0,GPL-2.0-only,GPL-3.0-only,AGPL-3.0-only,LGPL-3.0-only, etc. None— no LICENSE file generated at all
Loops
{{ range $index, $item := .features }}
{{ $index }}: {{ $item }}
{{ end }}
$index is zero-based and $item is the current value. continue and break work inside loops.
For simple iteration you can also use the dot directly:
{{ range .features }}
- {{ . }}
{{ end }}
Variables
{{ $slug := .project_name | lower | snake_case }}
module github.com/me/{{ $slug }}
Variables start with $. Use := to declare and = to reassign.
Pipes
{{ .project_name | upper | printf "Project: %s" }}
A pipe sends the left side as the last argument to the next function. You can chain as many as you want.
Whitespace control
{{ .name -}}
{{- .name }}
{{- .name -}}
- eats whitespace on that side. Use it to avoid stray blank lines around actions.
Named templates
{{ define "header" }}
// Generated by spin — do not edit
{{ end }}
{{ template "header" . }}
define creates a reusable chunk. template renders it. block does both at once.
Other built-ins
len (length of string/slice/map), index (access by index/key), slice (extract sub-slice), printf (formatted output).
Built-in template functions
| Function | Example | Output |
|---|---|---|
upper | {{ .project_name | upper }} | MYAPP |
lower | {{ .project_name | lower }} | myapp |
title | {{ .project_name | title }} | Myapp |
trim | {{ .description | trim }} | trimmed string |
join | {{ join .features ", " }} | auth, db |
default | {{ default "myapp" .name }} | uses default if empty |
snake_case | {{ .project_name | snake_case }} | my_app |
kebab | {{ .project_name | kebab }} | my-app |
quote | {{ .project_name | quote }} | shell-quoted string |
now | {{ now "2006" }} | current year |
contains | {{ if contains .tags "rust" }}...{{ end }} | bool |
has | {{ if has .features "auth" }}...{{ end }} | true if a []string contains the item |
not_has | {{ if not_has .features "auth" }}...{{ end }} | true if a []string does not contain the item |
one_of | {{ if one_of .license "MIT" "Apache-2.0" }}...{{ end }} | true if the value equals any of the items |
Non-template files
Files without .tmpl are copied as-is. This is useful for binary assets, static config, or files that do not need substitution.
Excluding files
Use exclude in spin.toml to skip files:
exclude = ["docs/*.md", "*.bak"]
Patterns support * (single directory segment) and ** (any number of segments).
Path safety
spin rejects any rendered path that resolves outside the destination directory. This protects users from malicious templates.
License generation
When a template declares a license param with type = "license", spin adds an extra step after rendering _base/*.tmpl files: it inspects the resolved license value.
- If the value is a known SPDX identifier (see supported licenses), spin generates a
LICENSEfile from the bundled canonical text. - The
copyright_holderand currentyearare substituted into the license text using Go template syntax. - If the value is
"None"or unknown, noLICENSEfile is generated (no error). - If the template already contains a
LICENSE,LICENSE.txt,LICENSE.md,COPYING, orCOPYING.txtfile in_base/, spin never overwrites it.
This means you can write:
{{ .name }} -- Copyright {{ now "2006" }} {{ .copyright_holder }}
to show the copyright line elsewhere, while spin still generates the full LICENSE file.
Outcomes
license value | copyright_holder | Result |
|---|---|---|
| known SPDX id | any | LICENSE written; year and holder substituted |
| known SPDX id | empty / absent | LICENSE written; ownership placeholder left as-is |
None / Proprietary / empty | any | no LICENSE file |
unknown id (or outside custom options) | any | no LICENSE file, no error |
| any | any | existing _base/LICENSE/COPYING file is never overwritten |
Supported licenses
The license texts are the canonical SPDX templates from the SPDX License List (v3.24+), bundled into spin at build time. Selecting a known ID generates the official text with year and copyright_holder substitution.
| SPDX ID | Result |
|---|---|
MIT | MIT License with <year> <copyright holders> |
Apache-2.0 | Apache License 2.0 |
BSD-2-Clause | BSD 2-Clause License |
BSD-3-Clause | BSD 3-Clause License |
GPL-2.0-only | GNU GPLv2 only |
GPL-3.0-only | GNU GPLv3 only |
LGPL-3.0-only | GNU LGPLv3 only |
MPL-2.0 | Mozilla Public License 2.0 |
AGPL-3.0-only | GNU AGPLv3 only |
Unlicense | The Unlicense |
CC0-1.0 | Creative Commons Zero v1.0 Universal |
ISC | ISC License |
0BSD | BSD Zero Clause License |
None | No file generated |
| unknown | No file generated (no error) |
The license generation is a side-effect: the
.licensevalue remains available in templates like any other param ({{ .license }}).
Common patterns
License header at the top of every file. Define it once and reuse with template:
{{ define "header" }}
// Copyright {{ now "2006" }} {{ .author }}
// SPDX-License-Identifier: {{ .license }}
{{ end }}
{{ template "header" . }}
package main
Module path from project name. Declare a variable to avoid repeating pipes:
{{ $slug := .project_name | lower | snake_case }}
module github.com/{{ .org }}/{{ $slug }}
Conditional file with [[include]]. Include a CI directory only when the user opts in:
# spin.toml
[params]
ci = { type = "bool", prompt = "Include CI?", default = true }
[[include]]
path = ".github/**"
if = "{{ .ci }}"
Conditional content inside a file. Show or hide blocks with if:
{{ if eq .license "MIT" }}
// MIT License text here
{{ else if eq .license "Apache-2.0" }}
// Apache 2.0 License text here
{{ end }}
Iterating features. Loop over a multiselect param:
{{ range .features }}
import _ "github.com/me/{{ . }}"
{{ end }}
Clean JSON with join. Use join to produce valid arrays:
{
"dependencies": ["{{ join .deps "\", \"" }}"]
}