A CI/CD Template for Terraform

Continuous integration (CI) makes the cycle from design to code to building artifacts seamless and consistent. Continuous delivery (CD) makes delivery of that artifact to an environment the same every time.

But, what about the actual environment the artifact is running in? Is it the same every time?

That’s a hard thing to guarantee — unless you take advantage of an Infrastructure-as-Code (IaC) approach. This post explains how to use Infrastructure-as-Code to improve CI/CD. We’ll use Terraform as our IaC tool, although the lessons below could be applied using any Infrastructure-as-Code solution.


What is Infrastructure-as-Code? The basic idea of Infrastructure-as-Code is to have the configuration files required to stand up the environment your code runs in, along with the actual code in your company’s source code management tool. Then, as part of the deployment process, your infrastructure automation tool of choice (e.g., Terraform) will build what is required as a step in the process. Then, your code will deploy on top of it. This allows all changes to the infrastructure to be tracked, along with the source code those files support, which allows for a truly reproducible deployment.

Terraform Basics

Using Terraform requires very basic steps. The first is to have your configuration files in their own directory structure. (Every organization has their own way to handle multiple environments – but for the purposes of this article, we’ll go with a flat directory structure.)

All configuration files are written in HashiCorp’s HCL format (which looks a lot like JSON) and it’s recommended they have the *.tf extension. The default file to load variables from is terraform.tfvars.

What Do the Terraform Scripts Look Like?

From a practical point of view, all these scripts could be in one file. But, I find that having everything separate makes maintenance easier as the project inevitably grows.


project_name = "temporary-test-account"
region = "us-central1"
zone = "us-central1-a"
cred_file = "~/serviceaccount.json"
network_name = "terraform-example"
variable "project_name" {
type = "string"

variable "region" {
type = "string"

variable "zone" {
type = "string"

variable "cred_file" {
type = "string"

variable "network_name" {
type = "string"

Creating a Provider creates a connection to a specific project and region within the Google Compute Platform:

provider "google" {
credentials = "${file(var.cred_file)}"
project = "${var.project_name}"
region = "${var.region}"

Creating Resources creates a custom network and subnet:

resource "google_compute_network" "vpc_network" {
name = "${var.network_name}"
auto_create_subnetworks = "false"

resource "google_compute_subnetwork" "vpc_subnet" {
name = "${var.network_name}"
ip_cidr_range = ""
network = "${google_compute_network.vpc_network.self_link}"
region = "${var.region}"
private_ip_google_access = true
} creates a single VM in the subnet (specified above):

data "template_file" "metadata_startup_script" {
template = "${file("")}"

resource "google_compute_instance" "vm_instance" {
name = "terraform-instance"
machine_type = "n1-standard-1"
zone = "${}"

boot_disk {
initialize_params {
image = "centos-cloud/centos-7"

metadata_startup_script = "${data.template_file.metadata_startup_script.rendered}"

network_interface {
network = "${google_compute_network.vpc_network.self_link}"
subnetwork = "${google_compute_subnetwork.vpc_subnet.self_link}"
access_config = {
} opens ports 22 and 80 so we can connect and test:

resource "google_compute_firewall" "fw_access" {
name = "terraform-firewall"
network = "${}"

allow {
protocol = "icmp"

allow {
protocol = "tcp"
ports = ["22", "80"]

source_ranges = [""]

Using the Templates Module in this case is loaded as a startup script in and passed as a variable. By leveraging the template module, you can have the startup script (or any other data) be dynamically updated to include things like environment-specific database connection strings, or even a password from a vault (which HashiCorp also offers).

# This is used as the startup script by the Google compute unit
# And will start an nginx container as an example

# Update everything
sudo yum -y update

# Install Docker pre-reqs
sudo yum -y install yum-utils device-mapper-persistent-data lvm2

# Remove any Docker installed by CentOS as a default
sudo yum -y remove docker-client docker-common docker

# Add the official Docker repo
sudo yum-config-manager --add-repo

# Install the official latest Docker Community Edition
sudo yum -y install docker-ce

# Enable and start the daemon
sudo systemctl start docker
sudo systemctl enable docker

# Starting nginx as a container as it is easy and always works
sudo docker run --name docker-nginx -p 80:80 -d nginx

Running Terraform

The first step is checking out the Infrastructure-as-Code project from your source code repository and setting any default variables before you can initialize Terraform. (This working demo is available on GitHub.)

terraform-cicd:$ cp terraform.tfvars.example terraform.tfvars
terraform-cicd:$ vi terraform.tfvars

Next, initialize Terraform, which downloads all the plugins required to use it:

terraform-cicd:$ terraform init

Initializing provider plugins...

  • Checking for available provider plugins on
  • Downloading plugin for provider "template" (2.1.2)...
  • Downloading plugin for provider "google" (2.6.0)...

The following providers do not have any version constraints in configuration, so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking changes, it is recommended to add version = "..." constraints to the corresponding provider blocks in configuration, with the constraint strings suggested below.

* version = "~> 2.6"
* provider.template: version = "~> 2.1"

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see any changes that are required for your infrastructure. All Terraform commands should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

The next required step in the latest version of Terraform is to apply, which creates the plan and executes it.

terraform-cicd:$ terraform apply

data.template_file.metadata_startup_script: Refreshing state...

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create

Terraform will perform the following actions:

+ google_compute_firewall.fw_access

id: <computed>

allow.#: "2"

allow.1367131964.ports.#: "0"

allow.1367131964.protocol: "icmp"

allow.186047796.ports.#: "2"

allow.186047796.ports.0: "22"

allow.186047796.ports.1: "80"

allow.186047796.protocol: "tcp"

creation_timestamp: <computed>

destination_ranges.#: <computed>

direction: <computed>

name: "terraform-firewall"

network: "terraform-example"

priority: "1000"

project: <computed>

self_link: <computed>

source_ranges.#: "1"

source_ranges.1080289494: ""

+ google_compute_instance.vm_instance

id: <computed>

boot_disk.#: "1"

boot_disk.0.auto_delete: "true"

boot_disk.0.device_name: <computed>

boot_disk.0.disk_encryption_key_sha256: <computed>

boot_disk.0.initialize_params.#: "1"

boot_disk.0.initialize_params.0.image: "centos-cloud/centos-7"

boot_disk.0.initialize_params.0.size: <computed>

boot_disk.0.initialize_params.0.type: <computed>

can_ip_forward: "false"

cpu_platform: <computed>

deletion_protection: "false"

guest_accelerator.#: <computed>

instance_id: <computed>

label_fingerprint: <computed>

machine_type: "n1-standard-1"

metadata_fingerprint: <computed>
metadata_startup_script: "#!/bin/bash\n#
This is used as the startup script by the Google compute unit\n# And will start an nginx container as an example\n\n# Update everything\nsudo yum -y update\n\n# Install Docker pre-reqs\nsudo yum -y install yum-utils device-mapper-persistent-data lvm2\n\n# Remove any Docker installed by CentOS as a default\nsudo yum -y remove docker-client docker-common docker\n\n# Add the official Docker repo\nsudo yum-config-manager --add-repo\n\n# Install the official latest Docker Community Edition\nsudo yum -y install docker-ce\n\n# Enable and start the daemon\nsudo systemctl start docker\nsudo systemctl enable docker\n\n# Starting nginx as a container as it is easy and always works\nsudo docker run --name docker-nginx -p 80:80 -d nginx\n\n"
name: "terraform-instance"
network_interface.#: "1"
network_interface.0.access_config.#: "1"
network_interface.0.access_config.0.assigned_nat_ip: <computed>
network_interface.0.access_config.0.nat_ip: <computed>
network_interface.0.access_config.0.network_tier: <computed>
network_interface.0.address: <computed> <computed>
network_interface.0.network_ip: <computed>
network_interface.0.subnetwork_project: <computed>
project: <computed>
scheduling.#: <computed>
self_link: <computed>
tags_fingerprint: <computed>
zone: <computed>

+ google_compute_network.vpc_network
id: <computed>
auto_create_subnetworks: "false"
delete_default_routes_on_create: "false"
gateway_ipv4: <computed>
name: "terraform-example"
project: <computed>
routing_mode: <computed>
self_link: <computed>

+ google_compute_subnetwork.vpc_subnet
id: <computed>
creation_timestamp: <computed>
fingerprint: <computed>
gateway_address: <computed>
ip_cidr_range: ""
name: "terraform-example"
private_ip_google_access: "true"
project: <computed>
region: "us-central1-a"
secondary_ip_range.#: <computed>
self_link: <computed>

Plan: 4 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes

google_compute_network.vpc_network: Creating...
google_compute_instance.vm_instance: Creation complete after 49s (ID: terraform-instance)
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Did it work?
terraform-cicd:$ gcloud compute instances list | egrep 'EXTERNAL_IP|terraform-instance'
terraform-instance us-central1-a n1-standard-1 RUNNING
terraform-cicd:$ curl
<!DOCTYPE html>

Yes it did! And now that we’ve tested it, it can go away.

terraform-cicd:$ terraform destroy
google_compute_network.vpc_network: Refreshing state... (ID: terraform-example)
data.template_file.metadata_startup_script: Refreshing state...
google_compute_firewall.fw_access: Refreshing state... (ID: terraform-firewall)
google_compute_subnetwork.vpc_subnet: Refreshing state... (ID: us-central1/terraform-example)
google_compute_instance.vm_instance: Refreshing state... (ID: terraform-instance)
An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: 

- destroy Terraform will perform the following actions: - google_compute_firewall.fw_access - google_compute_instance.vm_instance - google_compute_network.vpc_network - google_compute_subnetwork.vpc_subnet 

Plan: 0 to add, 0 to change, 4 to destroy. Do you really want to destroy all resources? Terraform will destroy all your managed infrastructure, as shown above. There is no undo. Only 'yes' will be accepted to confirm. Enter a value: yes
google_compute_firewall.fw_access: Destroying... (ID: terraform-firewall)
google_compute_instance.vm_instance: Destroying... (ID: terraform-instance)
google_compute_firewall.fw_access: Destruction complete after 8s
google_compute_instance.vm_instance: Still destroying... (ID: terraform-instance, 10s elapsed)
google_compute_instance.vm_instance: Destruction complete after 2m10s
google_compute_subnetwork.vpc_subnet: Destroying... (ID: us-central1/terraform-example)
google_compute_subnetwork.vpc_subnet: Still destroying... (ID: us-central1/terraform-example, 10s elapsed)
google_compute_subnetwork.vpc_subnet: Still destroying... (ID: us-central1/terraform-example, 20s elapsed)
google_compute_subnetwork.vpc_subnet: Destruction complete after 27s
google_compute_network.vpc_network: Destroying... (ID: terraform-example)
google_compute_network.vpc_network: Still destroying... (ID: terraform-example, 10s elapsed)
google_compute_network.vpc_network: Still destroying... (ID: terraform-example, 20s elapsed)
google_compute_network.vpc_network: Destruction complete after 27s

Destroy complete! Resources: 4 destroyed.

What About Integrating Terraform with an Existing CI/CD Pipeline?

From popular services like Circle CI to the ever-present Jenkins, regardless of which tool you use for CI/CD, it either already has a plugin for Terraform or you can simply run it from the command line, as detailed above.


By leveraging environment creation and cleanup as part of your CI/CD pipelines, you can reduce the number of incidents that occur as deployments move between environments, as everything is generated from developer-supported templates. Any events that are generated as part of a build can easily be tagged and routed through your incident management platform, and end up with the development team supporting that application service.

Maintain a fast, reliable CI/CD pipeline and respond to incidents during deployment with Splunk Observability Cloud. Sign up for a 14-day free trial to see how DevOps teams are maintaining CI/CD and making on-call suck less with a holistic incident management and real-time response solution.

About the Author

Vince Power is a Solution Architect who has a focus on cloud adoption and technology implementations using open source-based technologies. He has extensive experience with core computing and networking (IaaS), identity and access management (IAM), application platforms (PaaS), and continuous delivery.

Posted by