Introducing Boilerplate
An open source, cross-platform project generator / scaffolding tool

An open source, cross-platform project generator / scaffolding tool
I’m happy to announce that we’ve we’ve open sourced a cross-platform project generator / scaffolding tool called Boilerplate, licensed under MPL 2.0: https://github.com/gruntwork-io/boilerplate.
We’ve used Boilerplate for years at Gruntwork to generate oft-repeated code, such as Terraform/OpenTofu projects, the Reference Architecture, and vending new AWS accounts and GitHub repos as part of DevOps Foundations. Boilerplate is similar to other project generator tools such as cookiecutter, yeoman, and copier, but with the advantages of being cross-platform (a single standalone binary), with support for typed input variables, validations, scripting, template composition, interactive and non-interactive modes, and more.
In this blog post, I’ll walk you through the following:
Example use cases
Boilerplate is useful whenever you find yourself repeatedly creating the same sort of file and folder structures over and over again, but need to be able to fill in some of the information dynamically, based on user input, such as:
- Generating a new Terraform/OpenTofu module that has all of your company’s patterns and conventions baked in: e.g., folder structure, file naming, documentation, automated testing, etc.
- Generating a new Java project with the standard directory layout.
- Generate a repo and folder structure for each new team or microservice as part of supporting developer self-service.
- Generating oft-repeated files, such as legal disclaimers or license at the top of each source file or in
LICENSE.txt
files.
Basic usage
The basic idea behind Boilerplate is that you create a template folder that contains:
- A
boilerplate.yml
file that configures the template, such as the input variables to gather from the user. - Any number of other files and folders that generate the code you want, using Go templating syntax to fill in those input variables where necessary, do loops, do conditionals, and so on.
Let’s go through an example of creating a Boilerplate template to generate a basic OpenTofu module. This could be a good way to help standardize your company around common coding conventions for your modules, including:
- File naming conventions: e.g.,
main.tf
,outputs.tf
,variables.tf
. - Documentation: e.g.,
README.md
. - License: e.g.,
LICENSE.txt
.
Create a template folder called tofu-module
and put a file called boilerplate.yml
into it with the following contents:
variables: - name: ModuleName description: The name of the module type: string - name: CopyrightInfo description: Copyright information to put in the README. Typically "Copyright <year> <company>." type: string default: "" - name: TofuVersion description: The version of OpenTofu to use type: string default: 1.6.2
This boilerplate.yml
declares three variables to gather from the user: ModuleName
, CopyrightInfo
, TofuVersion
. Note how each input variable has an description
, type
, and default
value.
Next, create a main.tf
with the following contents:
terraform { required_version = "{{ .TofuVersion }}" }
The preceding code is configuring the version of OpenTofu to use, but instead of hard-coding the value, the code above uses Go templating syntax ({{ ... }}
) to fill in the value from the TofuVersion
input variable declared earlier in boilerplate.yml
.
Next, create a README.md
with the following contents:
# {{ .ModuleName | title }} module TODO: fill in documentation for the {{ .ModuleName | kebabcase }} module. ## License {{ if .CopyrightInfo -}} {{ .CopyrightInfo }} {{ end -}} Please see [LICENSE.txt](LICENSE.txt) for details on how the code in this repo is licensed.
This file has more Go templating syntax to fill in data based on the input variables you declared earlier in boilerplate.yml
, including ModuleName
and CopyrightInfo
. Note the use of the title
and kebabcase
functions: Boilerplate comes with all the sprig helpers built-in, so this code is making use of the title helper and kebabcase helper to format the module name in title case or kebab case, respectively.
The code above also contains a simple if-statement, ensuring that the data in .CopyrightInfo
is only rendered if it’s non-empty. Go templating syntax lets you use conditionals, loops, functions, etc.
You may want to add variables.tf
, outputs.tf
, and LICENSE.txt
files to your template as well. Have a look at the tofu-module example
for the full code sample.
To generate a module from this template folder, run the following command:
boilerplate \ --template-url tofu-module \ --output-folder /tmp/tofu-module
The preceding command tells boilerplate
to use the template folder tofu-module
and to put the rendered output into the output folder /tmp/tofu-module
. When you run this command, Boilerplate will to prompt you for each input variable:
ModuleName The name of the module ? Please enter ModuleName: vpc TofuVersion The version of OpenTofu to use ? Please enter TofuVersion: 1.5.7 CopyrightInfo Copyright information to put in the README. Typically "Copyright <year> <company>." ? Please enter CopyrightInfo: Copyright 2024 Gruntwork, Inc.
Boilerplate shows you the variable name, description, and allows you to interactively type in the value for each variable. If you don’t want to do this interactively, you can run Boilerplate with the --non-interactive
flag, and instead set the input variables via one or more --var
flags. Alternatively, you can set all the values in a YAML file:
ModuleName: vpc CopyrightInfo: Copyright 2024 Gruntwork, Inc. TofuVersion: 1.6.2
And pass the path of that YAML file to Boilerplate via the --var-file
flag:
boilerplate \ --template-url tofu-module \ --output-folder /tmp/tofu-module \ --var-file vars.yml \ --non-interactive
Boilerplate will go through all the files in your template folder and render them into the output folder:
$ cd /tmp/tofu-module $ tree . ├── LICENSE.txt ├── README.md ├── main.tf ├── outputs.tf └── variables.tf
If you open up the files in the output folder, you’ll see that Go templating syntax is now fully rendered based on the input variables values you entered. For example, here’s the rendered main.tf
:
terraform { required_version = "1.6.2" }
And here’s the rendered README.md
:
# Vpc module TODO: fill in documentation for the vpc module. ## License Copyright 2024 Gruntwork, Inc. Please see [LICENSE.txt](LICENSE.txt) for details on how the code in this repo is licensed.
More advanced usage
Now that you’ve seen basic project generation in action, let’s look at a couple of the more advanced features available in Boilerplate:
dependencies
: create more complicated templates by composing together simpler templates.hooks
: add scripting to your templates to perform actions that can’t be done with pure templating.
We’ll piggy back on top of the OpenTofu example in the previous section, but now, instead of generating just a single module, we’re going to generate our full recommended folder structure, which includes:
modules
: The module itself goes into themodules
folder. You’ll reuse thetofu-module
template from the previous section to create the module.examples
: An example usage of your module goes into theexamples
folder. These examples are essentially executable documentation, both showing how to use your code, and giving you both a manual and automated test harness for testing that code.test
: An automated test for the example code in theexamples
folder.
Create a new template folder called tofu-example
and put a new boilerplate.yml
file in it with the following contents:
variables: - name: ModuleName description: The name of the module type: string - name: ModuleSource description: The source URL (or file path) to use for the module type: string - name: TofuVersion description: The version of OpenTofu to use type: string default: 1.6.2 hooks: after: # Format the code - command: tofu args: - fmt dir: "{{ outputFolder }}"
This is very similar to the boilerplate.yml
from the previous section, declaring a few input variables at the top, but there’s something new on the bottom: a hooks
section that defines scripts to run, either before code generation (in a before
section) or after code generation (in an after
section). The preceding code uses the after
section to run tofu fmt
to apply standard formatting to the generated code.
Add a main.tf
file to the tofu-example
folder with the following contents:
terraform { required_version = "{{ .TofuVersion }}" } module "{{ .ModuleName | snakecase }}" { source = "{{ .ModuleSource }}" example_required_input = "Hello" example_optional_input = "World" }
This code will “instantiate” the module generated by the tofu-module
template in the previous section. You should also add an outputs.tf
file to tofu-example
to proxy through the output variables from the module:
output "example_output" { description = "example output" value = module.{{ .ModuleName | snakecase }}.example_output }
Have a look at tofu-example
for the full code sample.
OK, that does it for the tofu-example
template. Next, create one more template folder called tofu-test
, with the following boilerplate.yml
:
variables: - name: ModuleName description: The name of the module type: string - name: ExamplePath description: The folder path to to the example usage of the module to test type: string hooks: after: # Format the Go code - command: goimports args: - "-w" - "." dir: "{{ outputFolder }}"
The code above defines a few input variables and makes use of hooks
to format code using goimports
. Next, create a file called {{ .ModuleName | snakecase }}_test.go
(yes, you can use Go templating syntax in file and folder names!) and put the following contents in it:
package test import ( "testing" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func Test{{ .ModuleName | camelcase }}(t *testing.T) { t.Parallel() opts := &terraform.Options{ TerraformDir: "{{ .ExamplePath }}", TerraformBinary: "tofu", } defer terraform.Destroy(t, opts) terraform.InitAndApply(t, opts) actualOutput := terraform.OutputRequired(t, opts, "example_output") assert.Equal(t, "Hello World", actualOutput) }
Have a look at the tofu-test example
for the full code sample.
This is Go code that uses the Terratest library to create an automated test for your code that does the following:
- Run
tofu init
. - Run
tofu apply
. - Check that the output variable
example_output
is equal to the value “Hello, World”. - Run
tofu destroy
at the end of the test.
OK, you now have three project templates. To put them together, create one final template folder called tofu-module-full
and put the following boilerplate.yml
in it:
variables: - name: ModuleName description: The name of the module type: string - name: CopyrightInfo description: Copyright information to put in the README. Typically "Copyright <year> <company>." type: string default: "" - name: TofuVersion description: The version of OpenTofu to use type: string default: 1.5.7 dependencies: - name: module template-url: ../tofu-module output-folder: "modules/{{ .ModuleName | kebabcase }}" - name: example template-url: ../tofu-example output-folder: "examples/{{ .ModuleName | kebabcase }}" variables: - name: ModuleSource description: The source URL (or file path) to use for the module type: string default: "../../modules/{{ .ModuleName | kebabcase }}" - name: test template-url: ../tofu-test output-folder: "test" variables: - name: ExamplePath description: The source URL (or file path) to use for the module type: string default: "../examples/{{ .ModuleName | kebabcase }}"
Have a look at the tofu-module-full example
for the full code sample.
The code above gathers a few input variables, as you’ve seen before, but also introduces something new: dependencies
. The dependencies
section allows you to specify other Boilerplate templates that should be rendered as part of rendering the current one. This allows you to combine simpler templates together into more complicated ones.
The code above uses all three templates you created earlier, configuring a different output-folder
for each of these dependencies: the tofu-module
template will be rendered into the modules
folder, the tofu-example
template will be rendered into the examples
folder, and the tofu-test
template will be rendered into the test
folder.
Note that, by default, any input variables in the top-level template are automatically passed down to the templates declared in dependencies
, so, for example, the ModuleName
, CopyrightInfo
, and TofuVersion
gathered in tofu-module-full
automatically get passed down to tofu-module
. However, you can set or override variables by adding a variables
section to each dependency, which is how the code above configures the proper values for ModuleSource
and ExamplePath
for tofu-example
and tofu-test
, respectively.
Run Boilerplate to render your tofu-module-full
template:
boilerplate \ --template-url tofu-module-full \ --output-folder /tmp/tofu-module-full \ --var-file vars.yml \ --non-interactive
Here are the files and folders that get generated:
$ cd /tmp/tofu-module-full $ tree . ├── examples │ └── vpc │ ├── README.md │ ├── main.tf │ └── outputs.tf ├── modules │ └── vpc │ ├── LICENSE.txt │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── variables.tf └── test └── vpc_test.go
You now have a fully working and testable OpenTofu module! Jump into examples/vpc
to manually test and see if it is working:
$ cd examples/vpc $ tofu apply Apply complete! Resources: 0 added, 0 changed, 0 destroyed. Outputs: example_output = "Hello World"
Looks good. Next, jump into the test/vpc
folder to run an automated test to check it’s working. On the very first run, you need to initialize the Go module and download dependencies by running go mod init
and then go mod tidy
:
$ cd test/vpc $ go mod init tofu-module-full go: creating new go.mod: module tofu-module-full $ go mod tidy go: finding module for package github.com/stretchr/testify/assert go: finding module for package github.com/gruntwork-io/terratest/modules/terraform go: found github.com/gruntwork-io/terratest/modules/terraform in github.com/gruntwork-io/terratest v0.46.11 go: found github.com/stretchr/testify/assert in github.com/stretchr/testify v1.8.4
And now you can run the automated tests with go test
:
$ go test -v TestVpc 2024-03-05T11:31:40-05:00 retry.go:91: tofu [init -upgrade=false] TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Running command tofu with args [init -upgrade=false] TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing the backend... TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing modules... TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing provider plugins... TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: OpenTofu has been successfully initialized! TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: You may now begin working with OpenTofu. Try running "tofu plan" to see TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: any changes that are required for your infrastructure. All OpenTofu commands TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: should now work. TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: If you ever set or change modules or backend configuration for OpenTofu, TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: rerun this command to reinitialize your working directory. If you forget, other TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: commands will detect it and remind you to do so if necessary. TestVpc 2024-03-05T11:31:40-05:00 retry.go:91: tofu [apply -input=false -auto-approve -lock=false] TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Running command tofu with args [apply -input=false -auto-approve -lock=false] TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: No changes. Your infrastructure matches the configuration. TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: OpenTofu has compared your real infrastructure against your configuration and TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: found no differences, so no changes are needed. TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Apply complete! Resources: 0 added, 0 changed, 0 destroyed. TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Outputs: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: example_output = "Hello World" TestVpc 2024-03-05T11:31:41-05:00 retry.go:91: tofu [output -no-color -json example_output] TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Running command tofu with args [output -no-color -json example_output] TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: "Hello World" TestVpc 2024-03-05T11:31:41-05:00 retry.go:91: tofu [destroy -auto-approve -input=false -lock=false] TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Running command tofu with args [destroy -auto-approve -input=false -lock=false] TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Changes to Outputs: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: - example_output = "Hello World" -> null TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: You can apply this plan to save these new output values to the OpenTofu TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: state, without changing any real infrastructure. TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Destroy complete! Resources: 0 destroyed. TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: --- PASS: TestVpc (0.13s) PASS ok tofu-module-full 0.526s
And there you go! A fully working OpenTofu module, example, and test, all automatically generated for you.
Give it a shot!
Of course, you can use Boilerplate for generating any type of code, and not just Terraform/OpenTofu. Now that Boilerplate is open source, give it a shot, and see what you can come up with:
https://github.com/gruntwork-io/boilerplate
Let us know how it works for you. Better yet, PRs are very welcome to make it even better!