Professional Documents
Culture Documents
3 Terraform Variables and Modules - Terraform in Depth
3 Terraform Variables and Modules - Terraform in Depth
3 Terraform Variables and Modules - Terraform in Depth
Please ignore grammar errors and typos. What you see is a raw manuscript; it has
not been copyedited.
Modules are different from Providers in a few ways. Modules are pure
Terraform HCL, so there’s no Go provider. In fact, a module can not define
new types or resources itself, and instead uses the Resources and Data
Sources provided by individual Providers. Modules do not have to be vendor
specific- they can incorporate Resources from multiple Providers. While
Providers expose Resources that are extremely low-level, mapping to a
specific infrastructure component, Modules can be used to create higher-
level abstractions over systems composed of multiple components.
In the last chapter, we discussed how companies might abstract their
Network stack into a reusable building block that can be reused throughout
the company. A module is how they would accomplish this. It’s also
common for companies to have modules for Services, such as having a
module that launches a Kubernetes cluster that developers can use. The use
of modules allows teams to create building blocks of systems that can be
composed together to create a larger platform.
CALLOUTS
Our original program was a “root level module”. This means that it is a
module meant to be turned into a Workspace and called directly. Root level
modules are turned into Workspaces, and act as an entrypoint into your
Terraform program. This does not refer to where the module resides in the
repository structure, rather it is the module that Terraform starts from. If
you’ve programmed in C or Java then you can compare the root level
module with the main method in each of these languages.
Root-level modules have some subtle differences from the more standard
reusable modules, so we’re going to make some simple changes. The first
thing we’re going to do is move our code to a new folder for our child
modules. In the Git repository we created in the last chapter we need to run
a few terminal commands.
$ mkdir -p modules/ec2_instance
$ mv *.tf modules/ec2_instance/
copy
The next thing we need to do is update our “providers.tf” file. This is where We can still com
we defined our required providers and their configuration. The required the provider blo
should provide
providers section inside of the Terraform Block will stay, as it tells
over the other?
Terraform what provider versions the module needs. We will need to
remove the Provider Blocks though, as they can only be used at the root
level module.
Listing 3.1 Providers.tf Changes
terraform { #A
required_providers {
aws = { #B
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
provider "aws" { #C
region = var.aws_region #C
} #C
show feedback
copy
Using our requirements from the start of the chapter we need to give our
users the ability to change the Instance Type and Subnet. This is done by
using Input Variables, which are defined with the variable block. Input
Variables give developers a way to specify behavior and configuration for
the module without having to edit the module itself. We’re going to create
two variables for our module, one for the instance type and another for the
subnet.
We want our module to be easy to use though, so we’re going to put some
restrictions on our inputs to prevent people from making mistakes. The
first thing we’re going to do is restrict the type of values we want. By
default, the value can be anything that Terraform uses- a String, Number,
Boolean, Object, or List. The resources we’re planning on using these
variables with expect strings for these values though, so we can specify that
here. This will cause Terraform to give a useful error if the wrong type is
passed through.
This isn’t the only restriction we can add. For the subnet_id variable we
know that AWS is expecting a string with a certain format. We can add a
“validation” subblock to our variable declaration and use that to make sure
the string matches what AWS is expecting. To do this we’re going to use the
length and regex functions to make sure that the string matches a regular
expression we wrote that matches what AWS wants for this string. In
Chapter 4 we’re going to talk a lot more about functions and expressions.
Listing 3.2 Adding variables.tf to our module
variable "instance_type" {
type = string #A
description = "The type of instance to launch." #B
default = "t3.micro" #C
}
variable "subnet_id" {
type = string #D
description = "The ID of the Subnet to launch the instance into."
validation { #E
condition = length(regexall("^subnet-[\\d|\\w]+$", var.subnet_id
error_message = "The subnet_id must match the pattern ^subnet-[\\d|\
}
}
copy
Once our inputs are defined we can refer to them anywhere else in our
module. In our particular use case we want to pass the variables to our
aws_instance resource so that our module users can tell the resource
what subnet to launch in and what instance type to use. This way our
module gains a lot of reusability as users aren’t locked into specific
configurations.
copy
output "aws_instance_ip" { #B
value = aws_instance.hello_world.private_ip
}
output "aws_instance" { #C
value = aws_instance.hello_world
}
copy
Inside the module create a new folder named “workspace”. We’re going to At some point, A
build out our new root-level module inside of this folder, and then use it to structure of fold
getting quite co
run our code. The first thing we need to do is configure our provider. This
the actual folde
means both setting up the Terraform Required Providers block and adding while executing
in the variable and provider blocks so we can select a region.
Listing 3.5 Providers for our Test Workspace
terraform {
required_providers {
aws = { #A
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
variable "region" { #B
type = string
default = "us-east-1"
description = "The AWS Region to connect and run the tests in."
}
provider "aws" { #C
region = var.region #D
}
copy
Next, we need to call our module. Create a file named “main.tf” inside of
the workspace and add the module block there. For the module source we
can use a relative path to point up a directory to our module, and for some
of the arguments we can rely on the default values. At this point we run into
a problem though. How do we populate the subnet_id? For that, we can use
a data lookup on the default VPC and its subnets, just as we originally had in
the module itself.
module "test_instance" {
source = "../"
subnet_id = data.aws_subnets.default.ids[0] #B
}
output "aws_instance_arn" { #C
value = module.test_instance.aws_instance_arn
}
copy
Now we’re ready to go! Inside of the workspace run terraform init to
turn the folder into a proper workspace, and then terraform apply to
run the plan and, after review, apply it.
3.1.5 Publish
The final step towards making our module reusable is to publish it
somewhere that other people can grab it. Terraform can pull modules from
a variety of sources, such as registries, filesystems, git repositories, and git
hosts such as Github, Gitlab, or Bitbucket. To start with we’ll use Github, as
it is easier to get started with.
In your Github account create a new public repository. It’s considered a best
practice to name module repositories with the format terraform-
PROVIDER-NAME , where PROVIDER is the provider that is focused on. In
our case we’re going to put several modules inside of a single repository, so
we’ll name this repository terraform-aws-in-depth .
Now locally you need to push your code up to Github. When you create a
new repository Github gives you instructions on how to upload a local
repository. Once you follow those instructions you’ll be able to push the
code up to Github.
Now that it’s published we can use it! Inside any of our projects, we can use
the Module block to reference and use our new module. When we run
terraform init Terraform will download and install the module for us.
variable "subnet_id" { #A
type = string
description = "The Subnet to launch this resource in."
}
module "my_instance" {
source = "github.com/YOU_USERNAME/terraform-aws-in-depth//modul
subnet_id = var.subnet_id #C
instance_type = "t3.large" #D
}
output "instance_arn" {
value = module.my_instance.aws_instance_arn
}
copy
3.2 Modules
A module is at its core a collection of Data Sources, Resources, and any
assets (configuration files, templates) that are bundled together into
reusable components. Modules are similar to Python Packages or Javascript
Modules. They are reusable libraries that can be packaged up and
distributed for reuse.
Let’s say we wanted to add a Virtual Private Network (VPN) service to our
VPC so we can connect our laptop to the resources in our account. Rather
than build it ourselves we could use a module someone else built. We can
pull a module from the Terraform Public Registry that builds the VPN for us.
identifier = "my-vpn" #C
subnet_ids = data.aws_subnets.default.ids #D
}
copy
Modules get their Arguments from the `variable` block and their Attributes
from the `output` block. These blocks are the main topic of the next chapter.
3.2.2 Module File Structure
Modules typically are made up of several Terraform files, all in the same
directory. Terraform has a specific file structure that it proposes and that
the community follows, but from a technical standpoint this structure isn’t
required. Developers will sometimes create one file modules that break this
structure and they work just like any other.
File Purpose
Submodules are modules that exist inside of a larger parent module. They
are stored in the `modules` directory of the parent module. They are used to
divide up functionality into smaller more manageable pieces.
Submodules follow the same structure as any other module. They store
their files in the same place, expect some level of documentation, and can
even be called and tested individually. In theory, it is also possible for
submodules to have their own submodules, but this is considered to be a
bad practice as it raises the level of complexity of a project.
3.3 Input, Output, and Local Variables
In most languages, including Terraform, variables are placeholders for
reusable values. Terraform has three different components that it calls
variables. The differences all relate to how the variable interacts with
modules.
If you are familiar with languages that use Functions you can think about
this another way. In languages that use functions, you have function
arguments to send information into the function, a return value that is used
to take information out of the function, and then all of the variables inside
of the function only exist in that function. In our case the Input variables
are the function arguments, the output variables are the return values, and
our local variables are the ones that exist only inside the module.
The only variables from outside of a Module that is available are the Input
variables, and the only internal variables in a Module that can be accessed
from outside of it are the Outputs. Locals can only be accessed inside of the
module.
Terraform variables have another unique feature that makes them very
different from most other languages: All Terraform variables are Constants.
That means that their value can only be set once during a program, and
then it can not be changed or altered during that run. This is because
Terraform is a Declarative language, not an Imperative one. Since it runs
the program based on the order of dependencies, not based on the order the
code was written, Terraform would have no way to know what order to
apply or use variable changes. Don’t worry though, it is possible to
implement logic and transforms using Local variables and we’ll tackle that
problem in this chapter.
variable "subnet_id" {
type = string
description = "The ID of the Subnet to launch the instance into."
}
variable "instance_type" {
type = string
description = "The type of instance to launch." #B
default = "t3.micro" #C
}
copy
There are times when a developer using Terraform will have to deal with
sensitive data. This can include passwords, API keys, or even credentials
meant to be passed to a provider. Terraform has special tools that make
dealing with sensitive data easier.
Terraform itself is pretty loud; that is it tends to log and display a lot of
information, including changes in parameters during the planning phase.
In general this is great because it makes it easier to verify changes. This has
some pretty big security implications though. If a variable contains data
that is supposed to remain private, such as a password or API key, then
displaying (and likely logging) those values could be considered a pretty
severe breach.
copy
Once a variable is marked as sensitive Terraform will avoid logging its
value. This “sensitive” attribute will follow the value around, so if the value
is used to create another value than that value will also be sensitive.
The use of Types helps to make programs written in Terraform more robust
and easy to debug. If a provider is expecting a boolean but gets a string it
will throw an error stating with a message describing both what it expected
and what it received.
The variable block has a “type” argument that takes “type constraints” for
the variable. A type constraint is a special Terraform construct that is used
to define the type. Some type constraints are really simple: if you want to
specify a string you simply write “string” for the constraint. Other type
constraints, such as those for objects, are much more complex. In section
3.7 we’ll go through the constraints for every type.
variable "numbers_only" {
type = number
default = 3.14
}
variable "booleans_only" {
type = bool
default = true
}
variable "list_of_strings" {
type = list(string) #B
default = ["list", "of", "strings"]
}
copy
By default, a variable can be any type, as Terraform does not require a type.
In this way, Terraform can be said to be loosely typed, at least at the module
level. Type constraints are optional, but they are also a sign of high-quality
code. It’s also possible to add custom validation rules to inputs, which we’ll
discuss in section 3.8.
3.5 Outputs
Modules are used to create resources that are expected to be used alongside
other modules and resources. For developers to do that they need to be able
to pull information out of a module. The output block makes this possible
by giving module developers a way to explicitly define the values they want
their module to return.
output "my_favorite_string" {
value = "Hello World" #B
}
copy
Outputs are a core part of Terraform. VPC modules return subnets that
machines can launch in them, while instance modules (like our EC2 one)
return IP addresses and machine IDs so other services can reference them.
Part of designing and building distributed systems is linking these
independent services together to form a larger platform, which means
taking the output of one module and using it as the input of another.
output "aws_instance_ip" {
description = "The IP Address for the private network interface on the
value = aws_instance.hello_world.private_ip
}
output "aws_instance" {
description = "The entire instance resource."
value = aws_instance.hello_world
}
copy
ingress { #B
description = "Allow traffic on port 443 from MyInstance"
from_port = 443 #C
to_port = 443 #C
protocol = "tcp"
cidr_blocks = ["${module.my_instance.aws_instance_ip}/32"] #D
}
}
copy
copy
depends_on = [
aws_lb_target_group_attachment.instance_attachement #A
]
}
copy
This is very much an edge case, and it’s possible to completely avoid this
feature for years. That being said it’s useful to know about in the off chance
it is needed.
3.6 Locals
Local variables only exist inside of the module where they are defined.
Earlier in the chapter we compared modules to functions inside of other
languages, with inputs acting as function arguments and outputs
acting as return values. If we expand that example, then locals are the
variables that only exist inside of the function themselves. Local variables
exist so modules can do their own internal processing and logic, so there’s
no reason for them to be accessible outside of the module. If for some
reason a module author wants to expose a local variable they would do so
by passing it as the value of an output .
Each locals block can take any number of arguments. Each argument is
considered an independent local variable and takes the assigned value.
In other words, if you have a locals block with arguments “alpha”, “bravo”,
and “charlie” then you’ve created three local variables with those
respective names.
locals { #B
delta = ["four", "five"] #B
echo = { #B
"foxtrot" = "six", #B
"golf" = "seven" #B
}
}
locals {
hotel = local.bravo #C
}
copy
locals {
alpha = 2 #B
}
copy
Terraform uses the “local” keyword followed by the name to refer to the
local (ie, “local.alpha”, “local.bravo”, “local.charlie”). Locals are only
visible inside their own module.
copy
The use of Types helps to make programs written in Terraform more robust
and easy to debug. If a provider is expecting a boolean but gets a string it
will throw an error stating with a message describing both what it expected
and what it received.
3.7.1 Strings
copy
Terraform allows for string interpolation: that is, when you define a new
string you can embed variables in it. This makes it easier to construct
smaller dynamic strings. There are easier ways to manipulate and construct
larger strings, such as the templatefile function that we’ll discuss in
chapter 4 alongside other string manipulation functions.
locals {
identifier = "${var.prefix}-service" #A
}
copy
3.7.2 Numbers
Terraform has a single Type for all numeric values, whether they’re whole
numbers, fractions, or negative numbers. The keyword for all of these is
number .
variable "another_number" {
type = number
default = 1.31 #B
}
variable "a_negative_number" {
type = number
default = -1 #C
}
copy
variable "enable_flag_disabled" {
type = bool
default = false #C
}
copy
3.7.4 Lists
Lists are values with multiple values known as elements. Every element in a
list has an index that specifies the element’s place in the list, starting with
zero.
The keyword for lists is “list”. When used as a type constraint it is expected
to be “list(type)”, although for backward compatibility reasons, Terraform
will also accept just “list” and convert it to “list(any)”.
variable "list_of_strings" {
type = list(string) #B
default = [
"us-east-1",
"us-west-2",
]
}
variable "list_of_lists" {
type = list(list(string)) #C
default = [
["one", "two", "three"],
["red", "green", "blue"]
]
}
show feedback
copy
Lists require that every element be the same type. You can have lists of
strings, lists of numbers, even lists of lists or objects- but you can’t have a
list that contains both numbers and strings.
copy
3.7.5 Sets
Sets are very similar to lists, with two major differences-
Sets are very useful when you need a group of elements that have no repeats
in them, and when you don’t care what their order is.
The keyword for sets is “set”, and like list the constraint takes a type
argument.
copy
3.7.6 Tuples
A tuple is a series of elements, similar to a list. Each element has an index
starting at zero and they are accessed in the same way that lists are. Tuples
differ from lists in several important ways:
Tuples have a set length. They have to have the exact number of
elements that were defined in the type constraint.
Each element in a tuple can have a different type. These types
should be defined in the type constraint, and the values for each
element must match that constraint.
3.7.7 Objects
Objects are collections of data organized by key. Every key in the object has
a corresponding value, and keys are always unique strings. With an object,
each key can have values of different types.
Objects have the keyword “object”, as well as the most complex type
constraints of any type. Object type constraints allow each key to be defined
with a specific type, and it allows for nested object types.
})
default = { #C
name = "default"
enabled = false
}
}
copy
default = {
alpha = "example" #A
}
}
locals {
delta = var.optional_keys["alpha"] #B
echo = var.optional_keys["bravo"] #C
foxtrot = var.optional_keys["charlie"] #D
}
copy
Objects can have keys that point to other objects. This allows for the
creation of pretty complex data structures with multiple levels of values.
default = {
key = {
subkey = {
nested_string = "hello world",
nested_tuple = ["one", "two", "three"]
}
}
}
}
copy
In general you want to avoid making your variables too complex. Deep
nesting and complex objects can make it harder to follow and modify code.
If you see yourself defining very complex inputs you should consider
whether it would be more clear if the input was broken up into multiple
simpler inputs.
3.7.8 Maps
Maps are very, very similar to Objects with just a single exception: all values
in a map have to be the same type. Other than that maps and objects are the
same. They are defined with the same syntax and are referred to in exactly
the same way. When reading the official documentation on the Terraform
website you will regularly see map and object used interchangeably.
Since maps only allow one type their constraints are much easier to
describe. They have a keyword of “map” and take a subtype as their only
argument “map(type)”.
variable "map_of_objects" {
type = map(object({ #B
string_key = string,
number_key = number
}))
}
copy
3.7.9 Null
The Null type is a very special type that only applies to a single value: null.
It represents a value that has not been set. A value that has been set to null
has basically not been set at all.
variable "flag" {
type = bool
default = null #B
description = "A flag that can be disabled, enabled, or unset."
}
locals {
flag = var.flag != null ? tostring(var.flag) : "default" #C
}
copy
The null keyword can not be used as a type constraint, as it would not allow
the value to be set to anything at all.
3.7.10 Any
The Any keyword is used to specify that a value of any type is accepted. It
can be used directly to state there are no restrictions on type, and it can be
used inside of other type constraints such as lists.
Any is the default for variable types and as the constraint for lists, objects,
sets, and other types that have children types. As such you don’t technically
need to specify it, however, it makes code much clearer when it is used.
copy
The Any keyword can be applied to Lists and Maps, but it does not change
the fact that all elements in both of those types have to be of the same type.
This means that a list that specifies a child type of “any” can be all strings
or all numbers, but it can’t combine strings and numbers.
variable "list_of_anything" {
type = list(any)
description = "This can be a list of any type as long as all elements
}
copy
The condition field can take any expression that evaluates to a bool. A major
limitation though is that it can only access itself, not any other inputs or
values. This limitation can complicate some of the more complex validation
that a user may want to do, particularly the kind where multiple variables
interact with each other.
There are also some limitations around the error message. According to the
Terraform documentation they require “at least one full English sentence
starting with an uppercase letter and ending with a period or question
mark.” Although it’s not likely they validate the English portion of that
requirement, it probably means you are limited in the characters you can
include.
validation { #B
condition = length(var.description) <= 125
error_message = "The description can not be longer than 25 character
}
}
copy
Since the validation block can be used multiple times it’s possible to mix
and match these. A developer could specify that a list has to be a certain
length, and then validate that all the values in the list match a regular
expression.
validation { #A
condition = var.my_integer <= 10
error_message = "The value must not be greater than 10."
}
validation { #B
condition = var.my_integer <= 10
error_message = "The value must not be less than 0."
}
validation { #C
condition = try(parseint(var.my_integer) == var.my_integer)
error_message = "The value must be an integer."
}
}
copy
3.9 Summary
Modules are the primary way that code is shared amongst
developers using Terraform.
Input variables are used to customize module behavior by passing
data into a module.
Output variables allow module users to pull information out of a
module for reuse.
Local variables exist only inside modules and are used for internal
data and logic processing.
All values have a type, and Inputs can be restricted to a specific
type.
Terraform has many types, such as Strings, Numbers, Booleans,
Lists, Sets, Tuples, Objects, and Maps.
Input variables can have additional validation checks using
custom logic.
This chapter sets the base for writing code to setup infrastructure, knowing
that what tools developer has in his toolbox. Author has mentioned
everything. It would have been nice if Author can share practical examples of
scenarios where complex value such as object maps tuple etc types should be
save
sitemap
Up next...
4 Expressions and Iteration
Modifying data through the use of expressions and functions
Operators and their uses
Some of the most commonly used functions
String generation using Templates
Multiplying resources with foreach and count
Iterating over and transforming objects and lists