Templates

Template rendering

How spin processes _base/ files through Go text/template with built-in helpers, excludes, and path safety.

.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

FunctionExampleOutput
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 LICENSE file from the bundled canonical text.
  • The copyright_holder and current year are substituted into the license text using Go template syntax.
  • If the value is "None" or unknown, no LICENSE file is generated (no error).
  • If the template already contains a LICENSE, LICENSE.txt, LICENSE.md, COPYING, or COPYING.txt file 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 valuecopyright_holderResult
known SPDX idanyLICENSE written; year and holder substituted
known SPDX idempty / absentLICENSE written; ownership placeholder left as-is
None / Proprietary / emptyanyno LICENSE file
unknown id (or outside custom options)anyno LICENSE file, no error
anyanyexisting _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 IDResult
MITMIT License with <year> <copyright holders>
Apache-2.0Apache License 2.0
BSD-2-ClauseBSD 2-Clause License
BSD-3-ClauseBSD 3-Clause License
GPL-2.0-onlyGNU GPLv2 only
GPL-3.0-onlyGNU GPLv3 only
LGPL-3.0-onlyGNU LGPLv3 only
MPL-2.0Mozilla Public License 2.0
AGPL-3.0-onlyGNU AGPLv3 only
UnlicenseThe Unlicense
CC0-1.0Creative Commons Zero v1.0 Universal
ISCISC License
0BSDBSD Zero Clause License
NoneNo file generated
unknownNo file generated (no error)

The license generation is a side-effect: the .license value 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 "\", \"" }}"]
}