Orchestration using Terraform and the Fastly Terraform Provider

Terraform is a tool developed by HashiCorp intended for building, changing, and versioning infrastructure. Configuration files are used to describe the resources you require, from which Terraform will generate an execution plan indicating the various operations needed to reach the desired state. Terraform then executes the plan to build the described infrastructure.

Why use it

Terraform helps to simplify the process of managing reproducible infrastructure by providing two distinct types of integration: providers and modules.

  • Provider: an abstraction on top of an API that makes it easy to consume the API and to manage resources (including importing pre-existing resources that were originally created outside of Terraform).
  • Module: a self-contained package of Terraform configuration that provides 'best practice' implementations for which your Terraform configuration can import and consume.

Both providers and modules are typically published to the Terraform Registry for the community to benefit from, but you are free to create and consume your own internal versions.

Fastly has developed a provider that makes it possible to use Terraform to configure, manage, and deploy Fastly services.

Fastly terminology and concepts

The following are key concepts, unique to the Fastly platform, which are relevant to the discussion of the Fastly Terraform provider and the objects it exposes.

NOTE: Refer to the Fastly glossary for a complete list of terms and concepts.

Service

A Fastly 'service' is configuration that belongs to a customer, but can also be thought of as a container for service related concepts such as domain names and backend servers. This is relevant because when using Terraform you will need to define a service as a top-level Terraform 'resource' which will then have other service related objects contained within it.

There are two types of Fastly services: VCL and Compute. You can create both types of services using the Fastly Terraform provider.

Service version

A service version represents a single, immutable, deployment of service configuration. When using Terraform, you will be able to control whether the service you have defined should result in a new version (i.e., be activated). It's possible for a new service version to be created but not activated (e.g., the service configuration is modified and left in a 'draft' mode).

Domains

A domain represents an internet hostname, such as www.example.com, and is required to be defined within a service before the service can be activated.

Backends

A backend (also known as an 'origin') is a non-Fastly internet host and is required to be defined within a service before the service can be activated.

Versionless resources

A versionless resource is one that can be modified outside of the typical service version lifecycle. Fastly provides three types of versionless resources: ACLs, Edge Dictionaries and Dynamic VCL snippets.

The ACL and Dictionary resources act as 'containers' for their relevant data (much like a service can be thought of as a container for these service related concepts). Typically you would define an ACL or Dictionary in Terraform and then use the Fastly API to populate it dynamically (without requiring a new service version to be created).

Dynamic Snippets can be modified independently from changes to your Fastly service. This means you can modify snippet code rapidly without deploying a service version that may not be ready for production.

Terraform terminology and concepts

The following are key concepts, unique to Terraform, which are relevant to the discussion of the Fastly Terraform provider and the objects it exposes.

NOTE: Refer to the glossary maintained by HashiCorp for a complete list of Terraform-specific terms and concepts.

Resources

A resource is a block of code that describes one or more infrastructure objects. These resources are typically made available for consumption via external Terraform providers.

Data sources

A data source is a resource that Terraform does not create or manage but uses to query for information. Data sources are also made available for consumption via external Terraform providers.

State file

A state file is a cache of your infrastructure and configuration. This state is used to compare the requirements defined in your Terraform configuration against the real world environment. If this state is damaged or lost, then Terraform will not be able to identify which resources should exist, be modified, or be deleted.

This state is stored by default in a local file named terraform.tfstate, but it can also be stored remotely, which works better in a larger team environment where multiple contributors might need to update the state file at the same time. Storing your state remotely offers the ability (depending on the storage provider) to 'lock' the state file for a period of time so that changes can be applied safely.

Plan and apply

Terraform provides a command line interface (CLI) for interacting with the Terraform configuration files you write as well as managing your state file. The two most commonly used subcommands of the CLI are plan and apply.

A "plan" shows any changes required by the current configuration, while "apply" executes the plan and ensures the real world matches the requirements defined within the configuration.

TCL vs. HCL

The most fundamental element within Terraform is its configuration file. The main purpose of the configuration file is to declare 'resources', which represent infrastructure objects. Terraform requires you to write your configuration using a domain-specific language (DSL) referred to as the Terraform Configuration Language (TCL).

HashiCorp Configuration Language (HCL) is typically used to describe the lower-level syntax upon which the Terraform language is built. Although TCL is the more accurate terminology to use when discussing your Terraform configuration files, it's more common to refer to these configuration files as HCLs.

Best practices

These recommendations aim to improve the security, maintainability, flexibility, and overall code comprehension of Terraform projects that orchestrate Fastly services.

Sensitive data

Terraform state can contain sensitive information, and by default this is stored in plain-text format. HashiCorp recommends treating the entire state file as sensitive and storing your Terraform state remotely using a remote storage solution that provides encryption of the state file.

State locking

If you have multiple users with access to the Terraform state, all applying changes at the same time, then using Terraform's state locking feature will help to avoid data races.

Configuration not data

Terraform is designed for managing configuration not data. As an example, a database resource could be defined using Terraform, but then populated with data using an external mechanism (such as an API or SDK).

This approach is especially relevant to Fastly's versionless resources, such as ACLs and Edge Dictionaries. The resource 'container' is created by Terraform and the Fastly service activated, then the content for these versionless resources can be populated separately using the Fastly API.

It's possible to store data in Terraform but it's not advisable to do so because Terraform has a network message size limit that, when exceeded, causes Terraform to fail and be unable to access your Terraform state.

With this in mind it's preferable to keep your data separate from the Terraform configuration, as it makes managing your resources more flexible, scalable, and less error prone.

Safely managing versionless resources

Some resources won't work until they contain data. Ideally data is not managed by Terraform, but it can be useful, especially when creating versionless resources (e.g., a Fastly ACL or Edge Dictionary) to initialize the resource with some data when Terraform creates the infrastructure for the first time.

NOTE: Prior to version 1.0.0 of the Fastly Terraform provider the default behaviour was to discard state drift if the items weren't defined in the HCL. The use of ignore_changes, a built-in Terraform meta-argument, would allow the user to specify fields to ignore and from which to allow the state to drift.

Terraform ignores any external changes to versionless resources unless a manage_* field (e.g. manage_entries, manage_items, manage_snippets depending on the type of resource) is set to true (the field is false by default) or ignore_changes is explicitly set. The use of ignore_changes takes precedence over manage_* fields.

For more information on configuring a versionless resource, see the Fastly Terraform documentation pages for ACL entries, Dictionary items and VCL dynamic snippets.

Splitting up your configuration file

Terraform allows you to structure your configuration definition files however best fits your working processes. You can store all your Terraform code within a single file, but it can be useful to divide up your configuration into multiple files, such as:

  • provider.tf: provider dependencies (a terraform configuration block with a nested required_providers block).
  • main.tf: resources and data source configuration.
  • variables.tf: input variable blocks, defining dynamic configuration options that can change each time terraform is executed.
  • outputs.tf: output variable blocks, defining what should be reported after the Terraform plan has been executed.

Structuring your Terraform configuration using modules

As Terraform configuration files grow, they may become large, highly repetitive, and difficult to manage, especially if one Terraform project is used to orchestrate multiple Fastly services. At this point a good solution is to introduce a module.

HINT: For simple, single-service Fastly Terraform configurations, modules are normally not necessary.

Modules enable you to restructure your configuration into separate directories that can then be imported within your main.tf.

Here is an example directory tree that highlights how you might use modules to restructure a Terraform project consisting of a VCL service and a Compute service.

.
├── main.tf
├── modules
│ ├── service-compute
│ │ ├── main.tf
│ │ └── provider.tf
│ └── service-vcl
│ ├── main.tf
│ ├── provider.tf
│ └── vcl
│ └── main.vcl
└── provider.tf

You will need to import these modules into your 'root' module (e.g., the main.tf in your Terraform project root directory) and, if either of the modules have defined any required input variables, you'll need to configure those as well.

Here is what the root main.tf might look like if it imported both the child modules (service-compute and service-vcl) without any additional inputs:

main.tf
Terraform HCL
1
2
3
4
5
6
7
module "vcl" {
source = "./modules/service-vcl"
}
module "compute" {
source = "./modules/service-compute"
}

Terraform expects its CLI to be run from your project's root directory, which means executing subcommands from the same directory as your main.tf. If any child module contains configuration attributes that need to reference files relative to the module's directory, then instead of hard coding a relative path consider using the path.module built-in Terraform expression. This approach is more flexible as it will adapt to any directory structure changes that might occur in future.

For example, consider the service-vcl/main.tf module that contains a vcl block needing to reference a main.vcl file:

service-vcl/main.tf
Terraform HCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
resource "fastly_service_vcl" "service" {
name = "Example Service"
domain {
name = "www.example.com"
}
backend {
address = "httpbin.org"
name = "httpbin"
}
vcl {
content = file("${path.module}/vcl/main.vcl")
main = true
name = "custom_vcl"
}
force_destroy = true
}

Configurable interfaces

To enable easier and safer updating of resources, we recommend using appropriate Terraform language features to support dynamically configurable resources, blocks and their attributes. This is typically achieved using a combination of variables and dynamic blocks.

Variables enable values and expressions to be reused, and also can help support multiple environments (e.g., a staging environment vs. a production environment) by passing unique values to the same underlying configuration while keeping the values decoupled from the resource code. Dynamic blocks is a Terraform feature that helps reduce configuration duplication by dynamically constructing repeatable nested blocks.

HINT: Variables come in multiple forms:

The following sections will look at two separate examples, one using input variables (the simpler example of the two) and the other using both input variables and dynamic blocks together.

Configuration with variables

A simple example of variable use can be shown when trying to configure a list of extensions and content types for a Fastly 'gzip' block. Using the standard approach of listing the values for each attribute would look something like the following:

1
2
3
4
5
gzip {
name = "file extensions and content types"
extensions = ["js"]
content_types = ["text/html"]
}

Automating configuration like this could be accomplished by creating two additional files (unless your project was also significantly small enough to warrant all your Terraform code existing within a single file):

  • variables.tf
  • inputs.tfvars

The following variable declaration, which defines two input variables called gzip_extensions and gzip_content_types, are placed inside the variables.tf file. Each defined variable states that it should contain a list of strings, and that there should be a default value assigned if no value was provided by the user when running terraform plan.

variables.tf
Terraform HCL
1
2
3
4
5
6
7
8
9
variable "gzip_extensions" {
type = list(string)
default = ["js"]
}
variable "gzip_content_types" {
type = list(string)
default = ["text/html"]
}

HINT: Set safe and sensible defaults to reduce the number of inputs users have to provide.

The original gzip attribute values can now be replaced with the following variable references:

1
2
3
4
5
gzip {
name = "file extensions and content types"
extensions = var.gzip_extensions
content_types = var.gzip_content_types
}

The values for each gzip variable declaration should be placed inside an inputs.tfvars (you can change the file name if you prefer), and this file would be what most users unfamiliar with Terraform would need to concern themselves with.

The contents of inputs.tfvars could look like the following:

inputs.tfvars
Terraform HCL
1
2
3
4
5
6
7
8
9
gzip_extensions = [
"css",
"js"
]
gzip_content_types = [
"text/html",
"text/css"
]

To generate a new Terraform plan would now require you to pass the .tfvars file via a -var-file flag to the plan subcommand:

$ terraform plan -var-file="inputs.tfvars"

The benefit of this approach is that a user wouldn't need to worry about how to define the gzip resource in Terraform code or how the variables syntax worked. All the user would have to do in the future to add more extensions and content types would be to add another string to each list.

This approach also lends itself to further automation by using scripting to populate the variable list of objects. Terraform also supports a JSON formatted variable file with a file extension of .tfvars.json which again can help support automating the contents of this file.

The -var-file flag can even be omitted if you're comfortable naming the file terraform.tfvars (which is a name Terraform automatically recognises and auto-loads for you) or if you use the file extension .auto.tfvars (e.g., inputs.auto.tfvars); however, we don't recommend you auto-load input data, as it's generally considered less ambiguous if users are explicit about where input values are coming from.

NOTE: Refer to the Terraform documentation for more information on using Terraform variables.

Configuration with dynamic blocks

This example is similar to the previous one but extends the use of variables with dynamic blocks to enable a simple configuration abstraction around defining multiple Fastly 'backends' blocks. Using the standard approach without dynamic blocks requires listing the backends individually:

1
2
3
4
5
6
7
8
9
backend {
name = "foo"
address = "foo.com"
}
backend {
name = "bar"
address = "bar.com"
}

As before, automating configuration like this can be accomplished by creating two additional files:

  • variables.tf
  • inputs.tfvars

The following variable declaration, which defines an input variable called backends, is placed inside a variables.tf file. The defined variable states that it should contain a list of objects, and that the object should consist of two attributes: name and address.

variables.tf
Terraform HCL
1
2
3
4
5
6
variable "backends" {
type = list(object({
name = string
address = string
}))
}

The original backend definitions can now be replaced with the following dynamic block:

main.tf
Terraform HCL
1
2
3
4
5
6
7
8
dynamic "backend" {
for_each = var.backends
content {
name = backend.value["name"]
address = backend.value["address"]
}
}

The values for the backends variable declaration are placed inside the inputs.tfvars, and again this file would be what most users unfamiliar with Terraform would need to concern themselves with.

The contents of inputs.tfvars could look like the following:

inputs.tfvars
Terraform HCL
1
2
3
4
5
6
7
8
9
10
backends = [
{
"name"="foo",
"address"="bar"
},
{
"name"="baz",
"address"="qux"
}
]

To generate a new Terraform plan would now require you to pass the .tfvars file via a -var-file flag to the plan subcommand:

$ terraform plan -var-file="inputs.tfvars"

The benefit of this approach is it enables dynamically changing the list of backends used for this configuration. You can now define multiple input variable files that configure different backends for each environment you have (e.g., test, staging, and production).

NOTE: Refer to the Terraform documentation for more information on using Terraform dynamic blocks.

Multiple environments

There are two approaches to defining multiple environments for your infrastructure: 'directories' and 'workspaces'. HashiCorp recommends you pick an option that best suits your requirements:

To separate environments with potential configuration differences, use a directory structure. Use workspaces for environments that do not greatly deviate from one another, to avoid duplicating your configurations.

Here we'll focus on the first option, which is to have separate directories that import shared modules. Earlier we demonstrated the use of modules to structure your Terraform configuration. This is an extension of that approach to support multiple environments. We'll demonstrate this using the following directory tree which defines two environments "stage" and "prod", and these environments each consist of a Fastly VCL service and a Fastly Compute service.

.
├── modules
│ ├── service-compute
│ │ ├── main.tf
│ │ ├── pkg.tar.gz
│ │ ├── provider.tf
│ │ └── variables.tf
│ └── service-vcl
│ ├── main.tf
│ ├── provider.tf
│ ├── variables.tf
│ └── vcl
│ └── main.vcl
├── prod
│ ├── inputs.tfvars
│ ├── main.tf
│ ├── provider.tf
│ └── variables.tf
└── stage
├── inputs.tfvars
├── main.tf
├── provider.tf
└── variables.tf

We'll start by showing the contents of each file within the stage directory, as the files and content are identical to prod with the only difference being that no variable value is provided for the production environment (as we'll be using its default value) while the stage equivalent will override the default for that environment.

provider.tf
Terraform HCL
1
2
3
4
5
6
7
8
terraform {
required_providers {
fastly = {
source = "fastly/fastly"
version = ">=1.0.0"
}
}
}
variables.tf
Terraform HCL
1
2
3
4
variable "subdomain" {
type = string
default = "stage"
}
main.tf
Terraform HCL
1
2
3
4
5
6
7
8
module "www" {
source = "../modules/service-vcl"
subdomain = var.subdomain
}
module "compute" {
source = "../modules/service-compute"
subdomain = var.subdomain
}
inputs.tfvars
Terraform HCL
1
subdomain = "staging"

The differences in the "prod" directory are as follows:

variables.tf
Terraform HCL
1
2
3
4
variable "subdomain" {
type = string
default = "www"
}
inputs.tfvars
Terraform HCL
1
# empty! so as to use the default value (see above)

Consider the two modules "service-compute" and "service-vcl". First the compute service, which highlights we're expecting a subdomain variable to be defined (otherwise the implementation is the standard Terraform configuration expected for the fastly_service_compute resource):

/modules/service-compute/main.tf
Terraform HCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
resource "fastly_service_compute" "service" {
name = "Compute Service"
domain {
name = "${var.subdomain}-compute.example.com"
}
package {
filename = "${path.module}/pkg.tar.gz"
source_code_hash = filesha512("${path.module}/pkg.tar.gz")
}
backend {
address = "httpbin.org"
name = "httpbin"
}
force_destroy = true
}

Next we'll look at the VCL service, which again highlights we're expecting a subdomain variable to be defined (otherwise the implementation is the standard Terraform configuration expected for the fastly_service_vcl resource):

/modules/service-vcl/main.tf
Terraform HCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
resource "fastly_service_vcl" "service" {
name = "Example Service"
domain {
name = "${var.subdomain}.example.com"
}
backend {
address = "httpbin.org"
name = "httpbin"
}
vcl {
content = file("${path.module}/vcl/main.vcl")
main = true
name = "custom_vcl"
}
force_destroy = true
}

The modules both have provider.tf and variables.tf files that are identical to each other. The provider.tf is the same content as the one defined for the prod and stage directories while the variables.tf is different in that, although it defines a subdomain variable, it doesn't define a default value as it expects the value to be passed through from the parent calling module (which is either going to be stage/main.tf or prod/main.tf).

In order to set up the Terraform configuration you'll need to run terraform init within both the stage directory and the prod directory. Once that is done, within each directory you would need to run the relevant plan or apply subcommand and provide values for the defined variables via the -var-file flag. For example:

$ terraform plan -var-file="inputs.tfvars"

HINT: The terraform CLI provides subcommands that help format your code for consistency (terraform fmt) and to ensure your configuration is valid (terraform validate).

Although there is some duplication between environments, you gain benefits such as:

  • Flexibility: the ability to selectively import modules between environments.
  • Safety: the isolation of Terraform state, which avoids accidental experimentation in staging.

A similarly dynamic approach is possible using Terraform's "Workspaces" feature. In this approach, the Terraform configuration is the same across each workspace and typically is configured using variables only. When using Workspaces it is also important to be aware of the workspace you are working in to avoid accidentally performing operations on the wrong environment.

HINT: HashiCorp provides guidelines on when to use the Terraform Workspace feature, and they recommend against using it to manage multiple environments.