Guide

Enabling Scalable Cloud Resources Using GitOps with UpCloud, Terraform Cloud, and Vault

Learn how to manage cloud resources in UpCloud using Terraform

This series of guides was developed in collaboration with UpCloud and Verifa.

First you will be introduced to the stepping stones of IaC and Terraform:

  • How to manage cloud resources in UpCloud using Terraform

After you have familiarized yourself with setting up and managing the cloud infrastructure, our upcoming guides will show you how to:

  • Configure Vault to secure your infrastructure

    • Pre-provisioned (HCP or dev) Vault

    • Secure SSH access to VMs and generate dynamic credentials for managed databases

    • Vault configuration with Terraform: updating the Terraform configuration from tutorial #1 to provide access to cloud resources through Vault

  • Use GitOps best practices with Terraform

    • Use Terraform to bootstrap Terraform Cloud (TFC) workspaces connected to GitHub

    • Standardize your infrastructure using the private registry and variable sets

    • Build a GitOps process where TFC applies changes when a pull request is merged to main

UpCloud 

UpCloud is a European Cloud Provider and a HashiCorp technology partner providing PaaS from 12 data centers around the globe. We are a strong believer of open source and openness in general. That’s why all of our services are available from public repositories and always with 100% Terraform support. Our mission is to make Infrastructure as Code easier for small and medium size businesses.

Verifa

Verifa is a Nordic-based crew of experienced DevOps and Cloud professionals dedicated to helping our customers with Continuous practices and Cloud adoption. We help teams unlock their continuous release potential through workshops, coaching, and implementation. We’re big fans of open source and the HashiCorp stack, and are certified with HashiCorp’s Vault and Terraform.

How to Manage Cloud Resources in UpCloud using Terraform

Prerequisites

To follow this tutorial you will need:

To authenticate with the UpCloud Terraform provider, set environment variables UPCLOUD_USERNAME and UPCLOUD_PASSWORD:

export UPCLOUD_USERNAME=<username>

export UPCLOUD_PASSWORD=<password>

Resources provisioned as part of this tutorial will incur some cost on the UpCloud account, make sure to clean up the created resources to save costs.

Writing the Configuration

When working with Terraform you want to always start in an empty directory since Terraform will search the current directory for any files ending with .tf. Let’s create a new directory:

mkdir upcloud-terraform

Switch to the empty directory and create a file called main.tf

cd upcloud-terraform

touch main.tf

Next open the main.tf file with your favorite text editor and insert the following configuration into the file:

terraform {

 required_providers {

   upcloud = {

     source  = "UpCloudLtd/upcloud"

     version = "~> 2.0"

   }

 }

}

 

provider "upcloud" {}

 

resource "upcloud_server" "this" {

 hostname = "my-ubuntu-server"

 zone     = "fi-hel1"

 plan     = "1xCPU-1GB"

 metadata = true

 

 template {

   storage = "Ubuntu Server 20.04 LTS (Focal Fossa)"

   size    = 25

 }

 

 network_interface {

   type = "public"

 }

 

 user_data = <<EOF

 #!/bin/bash

 echo "Hello World!" >> /root/hello.txt

 EOF

 

 login {

   user = "terraform"

 

   keys = [

     file("~/.ssh/id_rsa.pub")

   ]

 }

}

 

output "server_public_ip" {

 value = upcloud_server.this.network_interface[0].ip_address

}
terraform {
 
 required_providers {
 
   upcloud = {
 
     source  = "UpCloudLtd/upcloud"
 
     version = "~> 2.0"
 
   }
 
 }
 
}
 
 
 
provider "upcloud" {}
 
 
 
resource "upcloud_server" "this" {
 
 hostname = "my-ubuntu-server"
 
 zone     = "fi-hel1"
 
 plan     = "1xCPU-1GB"
 
 metadata = true
 
 
 
 template {
 
   storage = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
   size    = 25
 
 }
 
 
 
 network_interface {
 
   type = "public"
 
 }
 
 
 
 user_data = <<EOF
 
 #!/bin/bash
 
 echo "Hello World!" >> /root/hello.txt
 
 EOF
 
 
 
 login {
 
   user = "terraform"
 
 
 
   keys = [
 
     file("~/.ssh/id_rsa.pub")
 
   ]
 
 }
 
}
 
 
 
output "server_public_ip" {
 
 value = upcloud_server.this.network_interface[0].ip_address
 
}

Now let’s break down what exactly is defined in the above Terraform configuration.

Terraform Block

Terraform configuration consists of many blocks, one of them is called simply terraform. This block is used mainly to declare which providers and their versions are used by the configuration and optionally also the version of Terraform CLI required can be declared. We will see soon how these values are used by Terraform.

Provider Block

A provider block is used to configure the Terraform provider(s) needed to provision the configured resources, in this case it is an empty block since we don’t need to pass any configuration options to the UpCloud provider. It’s common to specify credentials in this block, but instead we will be using the environment variables set previously.

Resource Block

The resource blocks are used to define the actual cloud infrastructure we are creating. In this case we will create a resource of type upcloud_server named simply this, since we are only creating a single instance of upcloud_server we don’t have to have a special name for it to distinguish it. In case you’re wondering, the upcloud_server resource will provision a virtual machine.

Output Block

After provisioning the resources via Terraform we likely want to know some information about the provisioned infrastructure. In this case we want to know the public IP address of the virtual machine once it has been created and the value is known, for this we can refer to the resource with a simple notation upcloud_server.this and then follow it with the attribute we’re interested in, in this case network_interface[0].ip_address.

Initialize Terraform

In order to create infrastructure Terraform requires providers and these providers are fetched to the machine executing Terraform using terraform init:

$ terraform init

Initializing the backend...


Initializing provider plugins...

- Finding upcloudltd/upcloud versions matching "~> 2.0"...

- Installing upcloudltd/upcloud v2.4.2...

- Installed upcloudltd/upcloud v2.4.2 (self-signed, key ID 60B4E1988F222907)

Partner and community providers are signed by their developers.

If you'd like to know more about provider signing, you can read about it here:

https://www.terraform.io/docs/cli/plugins/signing.htmlhttps://www.terraform.io/docs/cli/plugins/signing.html


 

Terraform has created a lock file .terraform.lock.hcl to record the provider

selections it made above. Include this file in your version control repository

so that Terraform can guarantee to make the same selections by default when

you run terraform init in the future.

 

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.

$ terraform init
 
Initializing the backend...
 
 
Initializing provider plugins...
 
- Finding upcloudltd/upcloud versions matching "~> 2.0"...
 
- Installing upcloudltd/upcloud v2.4.2...
 
- Installed upcloudltd/upcloud v2.4.2 (self-signed, key ID 60B4E1988F222907)
 
Partner and community providers are signed by their developers.
 
If you'd like to know more about provider signing, you can read about it here:
 
https://www.terraform.io/docs/cli/plugins/signing.htmlhttps://www.terraform.io/docs/cli/plugins/signing.html
 
 
 
 
Terraform has created a lock file .terraform.lock.hcl to record the provider
 
selections it made above. Include this file in your version control repository
 
so that Terraform can guarantee to make the same selections by default when
 
you run terraform init in the future.
 
 
 
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.

After initialization there is a new folder called .terraform which contains the provider(s) and a file called .terraform.lock.hcl which makes sure we will always fetch the same version of the provider(s) the next time terraform init is used. We can also see the file change if the provider is upgraded to a newer version.

Creating UpCloud resources with Terraform

Before creating infrastructure we can run terraform plan which will validate the configuration and print out a plan of the changes which also shows if the cloud infrastructure has drifted from our local configuration:

$ terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

 + create

 

Terraform will perform the following actions:

 

 # upcloud_server.this will be created

 + resource "upcloud_server" "this" {

     + cpu       = (known after apply)

     + hostname  = "my-ubuntu-server"

     + id        = (known after apply)

     + mem       = (known after apply)

     + metadata  = true

     + plan      = "1xCPU-1GB"

     + user_data = <<-EOT

             #!/bin/bash

             echo "Hello World!" >> /root/hello.txt

       EOT

     + zone      = "fi-hel1"

 

     + login {

         + create_password   = false

         + keys              = [

             + <<-EOT

                   ssh-rsa AAAAB3NzaC1FGMEDFXv...

               EOT,

           ]

         + password_delivery = "none"

         + user              = "terraform"

       }

 

     + network_interface {

         + bootable            = false

         + ip_address          = (known after apply)

         + ip_address_family   = "IPv4"

         + ip_address_floating = (known after apply)

         + mac_address         = (known after apply)

         + network             = (known after apply)

         + source_ip_filtering = true

         + type                = "public"

       }

 

     + template {

         + address = (known after apply)

         + id      = (known after apply)

         + size    = 25

         + storage = "Ubuntu Server 20.04 LTS (Focal Fossa)"

         + tier    = (known after apply)

         + title   = (known after apply)

       }

   }

 

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

 

Changes to Outputs:

 + server_public_ip = (known after apply)
$ terraform plan
 
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 
 + create
 
 
 
Terraform will perform the following actions:
 
 
 
 # upcloud_server.this will be created
 
 + resource "upcloud_server" "this" {
 
     + cpu       = (known after apply)
 
     + hostname  = "my-ubuntu-server"
 
     + id        = (known after apply)
 
     + mem       = (known after apply)
 
     + metadata  = true
 
     + plan      = "1xCPU-1GB"
 
     + user_data = <<-EOT
 
             #!/bin/bash
 
             echo "Hello World!" >> /root/hello.txt
 
       EOT
 
     + zone      = "fi-hel1"
 
 
 
     + login {
 
         + create_password   = false
 
         + keys              = [
 
             + <<-EOT
 
                   ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
               EOT,
 
           ]
 
         + password_delivery = "none"
 
         + user              = "terraform"
 
       }
 
 
 
     + network_interface {
 
         + bootable            = false
 
         + ip_address          = (known after apply)
 
         + ip_address_family   = "IPv4"
 
         + ip_address_floating = (known after apply)
 
         + mac_address         = (known after apply)
 
         + network             = (known after apply)
 
         + source_ip_filtering = true
 
         + type                = "public"
 
       }
 
 
 
     + template {
 
         + address = (known after apply)
 
         + id      = (known after apply)
 
         + size    = 25
 
         + storage = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
         + tier    = (known after apply)
 
         + title   = (known after apply)
 
       }
 
   }
 
 
 
Plan: 1 to add, 0 to change, 0 to destroy.
 
 
 
Changes to Outputs:
 
 + server_public_ip = (known after apply)

 

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run terraform apply now.

In this plan there is no drift (0 to change), but instead the plan is to add a resource.

To create infrastructure with Terraform the terraform apply command is used which will first print the output of terraform plan so we can verify that Terraform will create the expected infrastructure:

$ terraform apply

 Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

 + create

 

Terraform will perform the following actions:

 

 # upcloud_server.this will be created

 + resource "upcloud_server" "this" {

     + cpu       = (known after apply)

     + hostname  = "my-ubuntu-server"

     + id        = (known after apply)

     + mem       = (known after apply)

     + metadata  = true

     + plan      = "1xCPU-1GB"

     + user_data = <<-EOT

             #!/bin/bash

             echo "Hello World!" >> /root/hello.txt

       EOT

     + zone      = "fi-hel1"

 

     + login {

         + create_password   = false

         + keys              = [

             + <<-EOT

                   ssh-rsa AAAAB3NzaC1FGMEDFXv...

               EOT,

           ]

         + password_delivery = "none"

         + user              = "terraform"

       }

 

     + network_interface {

         + bootable            = false

         + ip_address          = (known after apply)

         + ip_address_family   = "IPv4"

         + ip_address_floating = (known after apply)

         + mac_address         = (known after apply)

         + network             = (known after apply)

         + source_ip_filtering = true

         + type                = "public"

       }

 

     + template {

         + address                  = (known after apply)

         + delete_autoresize_backup = false

         + filesystem_autoresize    = false

         + id                       = (known after apply)

         + size                     = 25

         + storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"

         + tier                     = (known after apply)

         + title                    = (known after apply)

       }

   }

 

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

 

Changes to Outputs:

 + server_public_ip = (known after apply)

 

Do you want to perform these actions?

 Terraform will perform the actions described above.

 Only 'yes' will be accepted to approve.

 

 Enter a value:

After reviewing the plan we can enter ‘yes’ in the prompt to accept the plan and let Terraform create the resources:

upcloud_server.this: Creating...

upcloud_server.this: Still creating... [10s elapsed]

upcloud_server.this: Still creating... [20s elapsed]

upcloud_server.this: Creation complete after 27s [id=0091aab7-9f3b-4e24-b993-22d8ccd0c7c7]

 

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

 

Outputs:

 

server_public_ip = "80.69.174.108"

$ terraform apply
 
 Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 
 + create
 
 
 
Terraform will perform the following actions:
 
 
 
 # upcloud_server.this will be created
 
 + resource "upcloud_server" "this" {
 
     + cpu       = (known after apply)
 
     + hostname  = "my-ubuntu-server"
 
     + id        = (known after apply)
 
     + mem       = (known after apply)
 
     + metadata  = true
 
     + plan      = "1xCPU-1GB"
 
     + user_data = <<-EOT
 
             #!/bin/bash
 
             echo "Hello World!" >> /root/hello.txt
 
       EOT
 
     + zone      = "fi-hel1"
 
 
 
     + login {
 
         + create_password   = false
 
         + keys              = [
 
             + <<-EOT
 
                   ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
               EOT,
 
           ]
 
         + password_delivery = "none"
 
         + user              = "terraform"
 
       }
 
 
 
     + network_interface {
 
         + bootable            = false
 
         + ip_address          = (known after apply)
 
         + ip_address_family   = "IPv4"
 
         + ip_address_floating = (known after apply)
 
         + mac_address         = (known after apply)
 
         + network             = (known after apply)
 
         + source_ip_filtering = true
 
         + type                = "public"
 
       }
 
 
 
     + template {
 
         + address                  = (known after apply)
 
         + delete_autoresize_backup = false
 
         + filesystem_autoresize    = false
 
         + id                       = (known after apply)
 
         + size                     = 25
 
         + storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
         + tier                     = (known after apply)
 
         + title                    = (known after apply)
 
       }
 
   }
 
 
 
Plan: 1 to add, 0 to change, 0 to destroy.
 
 
 
Changes to Outputs:
 
 + server_public_ip = (known after apply)
 
 
 
Do you want to perform these actions?
 
 Terraform will perform the actions described above.
 
 Only 'yes' will be accepted to approve.
 
 
 
 Enter a value:
 
After reviewing the plan we can enter ‘yes’ in the prompt to accept the plan and let Terraform create the resources:
 
upcloud_server.this: Creating...
 
upcloud_server.this: Still creating... [10s elapsed]
 
upcloud_server.this: Still creating... [20s elapsed]
 
upcloud_server.this: Creation complete after 27s [id=0091aab7-9f3b-4e24-b993-22d8ccd0c7c7]
 
 
 
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
 
 
 
Outputs:
 
 
 
server_public_ip = "80.69.174.108"
 

Accessing the Machine

Just to make sure everything worked as we expected we can login and see that the script specified in user_data has been executed on the machine:

$ ssh terraform@$(terraform output -raw server_public_ip)

terraform@my-ubuntu-server:~$ uptime

05:50:09 up 0 min,  1 user,  load average: 0.10, 0.03, 0.01

terraform@my-ubuntu-server:~$ sudo cat /root/hello.txt

Hello World!

Notice how we can access the machine easily by leveraging the output specified in the configuration.

Inspecting the State and Outputs

In order to discover potentially useful outputs to add to the configuration or otherwise find out more about the created infrastructure we can run terraform show which prints out what is written into the Terraform state about the resources:

$ terraform show

# upcloud_server.this:

resource "upcloud_server" "this" {

   cpu       = 1

   firewall  = false

   hostname  = "my-ubuntu-server"

   id        = "0092d8a5-6e9b-4a50-b3e0-ad008bd32d66"

   mem       = 1024

   metadata  = true

   plan      = "1xCPU-1GB"

   user_data = <<-EOT

         #!/bin/bash

         echo "Hello World!" >> /root/hello.txt

   EOT

   zone      = "fi-hel1"

 

   login {

       create_password   = false

       keys              = [

           <<-EOT

               ssh-rsa AAAAB3NzaC1FGMEDFXv...

           EOT,

       ]

       password_delivery = "none"

       user              = "terraform"

   }

 

   network_interface {

       bootable            = false

       ip_address          = "80.69.174.193"

       ip_address_family   = "IPv4"

       ip_address_floating = false

       mac_address         = "5e:9f:e9:d3:29:99"

       network             = "03000000-0000-4000-8001-000000000000"

       source_ip_filtering = true

       type                = "public"

   }

 

   template {

       address                  = "virtio"

       delete_autoresize_backup = false

       filesystem_autoresize    = false

       id                       = "01aa6043-7dd4-4175-9245-40a608eef53e"

       size                     = 25

       storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"

       tier                     = "maxiops"

       title                    = "terraform-my-ubuntu-server-disk"

   }

}

 

 

Outputs:

 

server_public_ip = "80.69.174.193"

$ terraform show
 
# upcloud_server.this:
 
resource "upcloud_server" "this" {
 
   cpu       = 1
 
   firewall  = false
 
   hostname  = "my-ubuntu-server"
 
   id        = "0092d8a5-6e9b-4a50-b3e0-ad008bd32d66"
 
   mem       = 1024
 
   metadata  = true
 
   plan      = "1xCPU-1GB"
 
   user_data = <<-EOT
 
         #!/bin/bash
 
         echo "Hello World!" >> /root/hello.txt
 
   EOT
 
   zone      = "fi-hel1"
 
 
 
   login {
 
       create_password   = false
 
       keys              = [
 
           <<-EOT
 
               ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
           EOT,
 
       ]
 
       password_delivery = "none"
 
       user              = "terraform"
 
   }
 
 
 
   network_interface {
 
       bootable            = false
 
       ip_address          = "80.69.174.193"
 
       ip_address_family   = "IPv4"
 
       ip_address_floating = false
 
       mac_address         = "5e:9f:e9:d3:29:99"
 
       network             = "03000000-0000-4000-8001-000000000000"
 
       source_ip_filtering = true
 
       type                = "public"
 
   }
 
 
 
   template {
 
       address                  = "virtio"
 
       delete_autoresize_backup = false
 
       filesystem_autoresize    = false
 
       id                       = "01aa6043-7dd4-4175-9245-40a608eef53e"
 
       size                     = 25
 
       storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
       tier                     = "maxiops"
 
       title                    = "terraform-my-ubuntu-server-disk"
 
   }
 
}
 
 
 
 
 
Outputs:
 
 
 
server_public_ip = "80.69.174.193"
 

Notice how all of the fields marked as ‘known after apply’ in the plan are now populated with actual values. Any of the values listed here can be exported using Terraform outputs.

Destroying the Machine

Once finished with the example machine let’s remove it using terraform destroy. Just like with apply, Terraform firstly shows the planned changes and after entering ‘yes’ the resources will be removed from UpCloud and from the terraform state:

> terraform destroy

upcloud_server.this: Refreshing state... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66]

 

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

 - destroy

 

Terraform will perform the following actions:

 

 # upcloud_server.this will be destroyed

 - resource "upcloud_server" "this" {

     - cpu       = 1 -> null

     - firewall  = false -> null

     - hostname  = "my-ubuntu-server" -> null

     - id        = "0092d8a5-6e9b-4a50-b3e0-ad008bd32d66" -> null

     - mem       = 1024 -> null

     - metadata  = true -> null

     - plan      = "1xCPU-1GB" -> null

     - user_data = <<-EOT

             #!/bin/bash

             echo "Hello World!" >> /root/hello.txt

       EOT -> null

     - zone      = "fi-hel1" -> null

 

     - login {

         - create_password   = false -> null

         - keys              = [

             - <<-EOT

                   ssh-rsa AAAAB3NzaC1FGMEDFXv...

               EOT,

           ] -> null

         - password_delivery = "none" -> null

         - user              = "terraform" -> null

       }

 

     - network_interface {

         - bootable            = false -> null

         - ip_address          = "80.69.174.193" -> null

         - ip_address_family   = "IPv4" -> null

         - ip_address_floating = false -> null

         - mac_address         = "5e:9f:e9:d3:29:99" -> null

         - network             = "03000000-0000-4000-8001-000000000000" -> null

         - source_ip_filtering = true -> null

         - type                = "public" -> null

       }

 

     - template {

         - address                  = "virtio" -> null

         - delete_autoresize_backup = false -> null

         - filesystem_autoresize    = false -> null

         - id                       = "01aa6043-7dd4-4175-9245-40a608eef53e" -> null

         - size                     = 25 -> null

         - storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)" -> null

         - tier                     = "maxiops" -> null

         - title                    = "terraform-my-ubuntu-server-disk" -> null

       }

   }

 

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

 

Changes to Outputs:

 - server_public_ip = "80.69.174.193" -> null

 

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

 

upcloud_server.this: Destroying... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66]

upcloud_server.this: Still destroying... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66, 10s elapsed]

upcloud_server.this: Destruction complete after 11s

 

Destroy complete! Resources: 1 destroyed.
> terraform destroy
 
upcloud_server.this: Refreshing state... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66]
 
 
 
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 
 - destroy
 
 
 
Terraform will perform the following actions:
 
 
 
 # upcloud_server.this will be destroyed
 
 - resource "upcloud_server" "this" {
 
     - cpu       = 1 -> null
 
     - firewall  = false -> null
 
     - hostname  = "my-ubuntu-server" -> null
 
     - id        = "0092d8a5-6e9b-4a50-b3e0-ad008bd32d66" -> null
 
     - mem       = 1024 -> null
 
     - metadata  = true -> null
 
     - plan      = "1xCPU-1GB" -> null
 
     - user_data = <<-EOT
 
             #!/bin/bash
 
             echo "Hello World!" >> /root/hello.txt
 
       EOT -> null
 
     - zone      = "fi-hel1" -> null
 
 
 
     - login {
 
         - create_password   = false -> null
 
         - keys              = [
 
             - <<-EOT
 
                   ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
               EOT,
 
           ] -> null
 
         - password_delivery = "none" -> null
 
         - user              = "terraform" -> null
 
       }
 
 
 
     - network_interface {
 
         - bootable            = false -> null
 
         - ip_address          = "80.69.174.193" -> null
 
         - ip_address_family   = "IPv4" -> null
 
         - ip_address_floating = false -> null
 
         - mac_address         = "5e:9f:e9:d3:29:99" -> null
 
         - network             = "03000000-0000-4000-8001-000000000000" -> null
 
         - source_ip_filtering = true -> null
 
         - type                = "public" -> null
 
       }
 
 
 
     - template {
 
         - address                  = "virtio" -> null
 
         - delete_autoresize_backup = false -> null
 
         - filesystem_autoresize    = false -> null
 
         - id                       = "01aa6043-7dd4-4175-9245-40a608eef53e" -> null
 
         - size                     = 25 -> null
 
         - storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)" -> null
 
         - tier                     = "maxiops" -> null
 
         - title                    = "terraform-my-ubuntu-server-disk" -> null
 
       }
 
   }
 
 
 
Plan: 0 to add, 0 to change, 1 to destroy.
 
 
 
Changes to Outputs:
 
 - server_public_ip = "80.69.174.193" -> null
 
 
 
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
 
 
 
upcloud_server.this: Destroying... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66]
 
upcloud_server.this: Still destroying... [id=0092d8a5-6e9b-4a50-b3e0-ad008bd32d66, 10s elapsed]
 
upcloud_server.this: Destruction complete after 11s
 
 
 
Destroy complete! Resources: 1 destroyed.

After removing the resources we will look into creating a bit more complex infrastructure and packaging that as a reusable module.

Terraform Modules

Terraform modules can be used to package a collection of resources to be used together in a reusable way. In fact, in the previous part of the tutorial we were using a module called the root module, whenever executing Terraform in a directory with *.tf files present that directory becomes the root module. Modules are a way to keep Terraform configuration DRY and provide an abstraction layer that allows authors to hide complex details and create infrastructure based on high level input variables. There are thousands of modules available from the Terraform registry which allow the creation of complex infrastructure by specifying just the input values.

Module Structure

Building on the main.tf from the previous part of the collection, let’s create a new folder called modules and a sub-folder called compute which will contain the upcloud_server resource from now on:

mkdir -p modules/compute

cd modules/compute

Let’s move the upcloud_server resource to a new main.tf file under the module directory and use Terraform variables to make some of the configuration more dynamic:

resource "upcloud_server" "this" {

 hostname = "${var.project}-${var.environment}-${var.server.name}"

 zone     = var.server.region

 plan     = var.server.plan

 metadata = true

 

 template {

   storage = var.server.image

   size    = var.server.disk_size

 }

 

 network_interface {

   type = "public"

 }

 

 user_data = <<EOF

 #!/bin/bash

 echo "Hello World!" >> /root/hello.txt

 EOF

 

 login {

   user = "terraform"

 

   keys = [

     file("~/.ssh/id_rsa.pub")

   ]

 }

}
resource "upcloud_server" "this" {
 
 hostname = "${var.project}-${var.environment}-${var.server.name}"
 
 zone     = var.server.region
 
 plan     = var.server.plan
 
 metadata = true
 
 
 
 template {
 
   storage = var.server.image
 
   size    = var.server.disk_size
 
 }
 
 
 
 network_interface {
 
   type = "public"
 
 }
 
 
 
 user_data = <<EOF
 
 #!/bin/bash
 
 echo "Hello World!" >> /root/hello.txt
 
 EOF
 
 
 
 login {
 
   user = "terraform"
 
 
 
   keys = [
 
     file("~/.ssh/id_rsa.pub")
 
   ]
 
 }
 
}

It’s a convention to place the configuration into files with well-known names such as outputs.tf, with this in mind let’s move the rest of the configuration into separate files:

outputs.tf:

output "server_public_ip" {

 value = upcloud_server.this.network_interface[0].ip_address

}

output "server_public_ip" {
 
 value = upcloud_server.this.network_interface[0].ip_address
 
}
 

versions.tf:

terraform {

 required_providers {

   upcloud = {

     source  = "UpCloudLtd/upcloud"

     version = "~> 2.0"

   }

 }

}
terraform {
 
 required_providers {
 
   upcloud = {
 
     source  = "UpCloudLtd/upcloud"
 
     version = "~> 2.0"
 
   }
 
 }
 
}

Now we’re still missing the file that holds the variables variables.tf let’s look at inputs and outputs next in more detail and create it.

Inputs and Outputs

When authoring a reusable module it’s important to carefully consider the inputs and outputs that the module exposes. As a module author, we don’t want to ask the user for all the possible input values, that would seem like writing the resource definition itself from the user perspective. On the other hand we do want to expose the names and applicable IDs of the resources created if the user is chaining together multiple modules that might reference a resource created as part of our module. However in this tutorial let’s keep it simple and continue with the example output since it’s all we need at this point:

outputs.tf:

output "server_public_ip" {

 value = upcloud_server.this.network_interface[0].ip_address

}
output "server_public_ip" {
 
 value = upcloud_server.this.network_interface[0].ip_address
 
}

We want to spend some time on the variables to define a helpful description and define the type:

variables.tf:

variable "project" {

 description = "Project which the resources belongs to"

 type        = string

}

 

variable "environment" {

 description = "Environment for the resources (e.g. prod/dev)"

 type        = string

}

 

variable "server" {

 description = "Server Configuration"

 type = object({

   name      = string

   image     = string

   disk_size = number

   region    = string

   plan      = string

 })

}
variable "project" {
 
 description = "Project which the resources belongs to"
 
 type        = string
 
}
 
 
 
variable "environment" {
 
 description = "Environment for the resources (e.g. prod/dev)"
 
 type        = string
 
}
 
 
 
variable "server" {
 
 description = "Server Configuration"
 
 type = object({
 
   name      = string
 
   image     = string
 
   disk_size = number
 
   region    = string
 
   plan      = string
 
 })
 
}

These details will help users of the module.

Configuration Using the Module

Let’s go back to the root folder from under the compute module directory:

cd ../../

Now let’s re-write the root main.tf file to consume the module:

terraform {

 required_providers {

   upcloud = {

     source = "UpCloudLtd/upcloud"

   }

 }

}

 

provider "upcloud" {}

 

module "compute" {

 source = "./modules/compute"

 

 project     = "project-x"

 environment = "dev"

 

 server = {

   name      = "my-server"

   image     = "Ubuntu Server 20.04 LTS (Focal Fossa)"

   disk_size = 25

   region    = "fi-hel1"

   plan      = "1xCPU-1GB"

 }

}
cd ../../
 
Now let’s re-write the root main.tf file to consume the module:
 
terraform {
 
 required_providers {
 
   upcloud = {
 
     source = "UpCloudLtd/upcloud"
 
   }
 
 }
 
}
 
 
 
provider "upcloud" {}
 
 
 
module "compute" {
 
 source = "./modules/compute"
 
 
 
 project     = "project-x"
 
 environment = "dev"
 
 
 
 server = {
 
   name      = "my-server"
 
   image     = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
   disk_size = 25
 
   region    = "fi-hel1"
 
   plan      = "1xCPU-1GB"
 
 }
 
}

Instead of a resource block the configuration uses a module block and we only pass in some key-value pairs which we defined in the variables.tf of the module.

In this example we’re using the module with filesystem paths:

module "compute" {

 source = "./modules/compute"

This is not exactly ideal if we want to version the module and it would be hard to share. In another part of this tutorial we will use the Terraform Cloud private registry instead as the source of the module.

When adding or removing modules we need to run terraform init again which downloads the module to the local .terraform directory just like it does for the providers:

$ terraform init

Initializing modules...

- compute in modules/compute

 

Initializing the backend...

 

Initializing provider plugins...

- Finding upcloudltd/upcloud versions matching "~> 2.0"...

- Installing upcloudltd/upcloud v2.4.2...

- Installed upcloudltd/upcloud v2.4.2 (self-signed, key ID 60B4E1988F222907)

 

Partner and community providers are signed by their developers.

If you'd like to know more about provider signing, you can read about it here:

https://www.terraform.io/docs/cli/plugins/signing.html

 

Terraform has created a lock file .terraform.lock.hcl to record the provider

selections it made above. Include this file in your version control repository

so that Terraform can guarantee to make the same selections by default when

you run "terraform init" in the future.

 

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.
$ terraform init
 
Initializing modules...
 
- compute in modules/compute
 
 
 
Initializing the backend...
 
 
 
Initializing provider plugins...
 
- Finding upcloudltd/upcloud versions matching "~> 2.0"...
 
- Installing upcloudltd/upcloud v2.4.2...
 
- Installed upcloudltd/upcloud v2.4.2 (self-signed, key ID 60B4E1988F222907)
 
 
 
Partner and community providers are signed by their developers.
 
If you'd like to know more about provider signing, you can read about it here:
 
https://www.terraform.io/docs/cli/plugins/signing.html
 
 
 
Terraform has created a lock file .terraform.lock.hcl to record the provider
 
selections it made above. Include this file in your version control repository
 
so that Terraform can guarantee to make the same selections by default when
 
you run "terraform init" in the future.
 
 
 
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.

Now we can apply the infrastructure again:

$ terraform apply

 

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

 + create

 

Terraform will perform the following actions:

 

 # module.compute.upcloud_server.this will be created

 + resource "upcloud_server" "this" {

     + cpu       = (known after apply)

     + hostname  = "project-x-dev-my-server"

     + id        = (known after apply)

     + mem       = (known after apply)

     + metadata  = true

     + plan      = "1xCPU-1GB"

     + user_data = <<-EOT

             #!/bin/bash

             echo "Hello World!" >> /root/hello.txt

       EOT

     + zone      = "fi-hel1"

 

     + login {

         + create_password   = false

         + keys              = [

             + <<-EOT

                   ssh-rsa AAAAB3NzaC1FGMEDFXv...

               EOT,

           ]

         + password_delivery = "none"

         + user              = "terraform"

       }

 

     + network_interface {

         + bootable            = false

         + ip_address          = (known after apply)

         + ip_address_family   = "IPv4"

         + ip_address_floating = (known after apply)

         + mac_address         = (known after apply)

         + network             = (known after apply)

         + source_ip_filtering = true

         + type                = "public"

       }

 

     + template {

         + address                  = (known after apply)

         + delete_autoresize_backup = false

         + filesystem_autoresize    = false

         + id                       = (known after apply)

         + size                     = 25

         + storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"

         + tier                     = (known after apply)

         + title                    = (known after apply)

       }

   }

 

Plan: 1 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

 

module.compute.upcloud_server.this: Creating...

module.compute.upcloud_server.this: Still creating... [10s elapsed]

module.compute.upcloud_server.this: Still creating... [20s elapsed]

module.compute.upcloud_server.this: Still creating... [30s elapsed]

module.compute.upcloud_server.this: Creation complete after 32s [id=00de3314-f4d7-4410-b85d-6fb958b35169]

 

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform apply
 
 
 
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 
 + create
 
 
 
Terraform will perform the following actions:
 
 
 
 # module.compute.upcloud_server.this will be created
 
 + resource "upcloud_server" "this" {
 
     + cpu       = (known after apply)
 
     + hostname  = "project-x-dev-my-server"
 
     + id        = (known after apply)
 
     + mem       = (known after apply)
 
     + metadata  = true
 
     + plan      = "1xCPU-1GB"
 
     + user_data = <<-EOT
 
             #!/bin/bash
 
             echo "Hello World!" >> /root/hello.txt
 
       EOT
 
     + zone      = "fi-hel1"
 
 
 
     + login {
 
         + create_password   = false
 
         + keys              = [
 
             + <<-EOT
 
                   ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
               EOT,
 
           ]
 
         + password_delivery = "none"
 
         + user              = "terraform"
 
       }
 
 
 
     + network_interface {
 
         + bootable            = false
 
         + ip_address          = (known after apply)
 
         + ip_address_family   = "IPv4"
 
         + ip_address_floating = (known after apply)
 
         + mac_address         = (known after apply)
 
         + network             = (known after apply)
 
         + source_ip_filtering = true
 
         + type                = "public"
 
       }
 
 
 
     + template {
 
         + address                  = (known after apply)
 
         + delete_autoresize_backup = false
 
         + filesystem_autoresize    = false
 
         + id                       = (known after apply)
 
         + size                     = 25
 
         + storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)"
 
         + tier                     = (known after apply)
 
         + title                    = (known after apply)
 
       }
 
   }
 
 
 
Plan: 1 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
 
 
 
module.compute.upcloud_server.this: Creating...
 
module.compute.upcloud_server.this: Still creating... [10s elapsed]
 
module.compute.upcloud_server.this: Still creating... [20s elapsed]
 
module.compute.upcloud_server.this: Still creating... [30s elapsed]
 
module.compute.upcloud_server.this: Creation complete after 32s [id=00de3314-f4d7-4410-b85d-6fb958b35169]
 
 
 
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Layers of Outputs

You might have noticed that there were no outputs printed by Terraform when applying the configuration utilizing the module, even though we did define the same server_public_ip output as before in the module. That’s needed, but not enough to expose the output from the root module. In order to get the output from the child module, we need to define a new output on the root module. Using the same naming convention as the module, add an outputs.tf file:

output "server_public_ip" {

 value = module.compute.server_public_ip

}
output "server_public_ip" {
 
 value = module.compute.server_public_ip
 
}

There’s no need to re-apply the configuration for an output, we just need to run terraform refresh:

$ terraform refresh

module.compute.upcloud_server.this: Refreshing state... [id=00de3314-f4d7-4410-b85d-6fb958b35169]

Outputs:

server_public_ip = "94.237.38.105"
$ terraform refresh
 
module.compute.upcloud_server.this: Refreshing state... [id=00de3314-f4d7-4410-b85d-6fb958b35169]
 
Outputs:
 
server_public_ip = "94.237.38.105"

Destroying the Infrastructure

Make sure to destroy the created infrastructure using terraform destroy:

$ terraform destroy

module.compute.upcloud_server.this: Refreshing state... [id=00de3314-f4d7-4410-b85d-6fb958b35169]

 

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

 - destroy

 

Terraform will perform the following actions:

 

 # module.compute.upcloud_server.this will be destroyed

 - resource "upcloud_server" "this" {

     - cpu       = 1 -> null

     - firewall  = false -> null

     - hostname  = "project-x-dev-my-server" -> null

     - id        = "00de3314-f4d7-4410-b85d-6fb958b35169" -> null

     - mem       = 1024 -> null

     - metadata  = true -> null

     - plan      = "1xCPU-1GB" -> null

     - user_data = <<-EOT

             #!/bin/bash

             echo "Hello World!" >> /root/hello.txt

       EOT -> null

     - zone      = "fi-hel1" -> null

 

     - login {

         - create_password   = false -> null

         - keys              = [

             - <<-EOT

                   ssh-rsa AAAAB3NzaC1FGMEDFXv...

               EOT,

           ] -> null

         - password_delivery = "none" -> null

         - user              = "terraform" -> null

       }

 

     - network_interface {

         - bootable            = false -> null

         - ip_address          = "94.237.38.105" -> null

         - ip_address_family   = "IPv4" -> null

         - ip_address_floating = false -> null

         - mac_address         = "5e:9f:e9:d3:53:a0" -> null

         - network             = "03000000-0000-4000-8035-000000000000" -> null

         - source_ip_filtering = true -> null

         - type                = "public" -> null

       }

 

     - template {

         - address                  = "virtio" -> null

         - delete_autoresize_backup = false -> null

         - filesystem_autoresize    = false -> null

         - id                       = "012bc348-8c0b-4aa9-a975-1e6857c9c9a5" -> null

         - size                     = 25 -> null

         - storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)" -> null

         - tier                     = "maxiops" -> null

         - title                    = "terraform-project-x-dev-my-server-disk" -> null

       }

   }

 

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

 

Changes to Outputs:

 - server_public_ip = "94.237.38.105" -> null

 

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

 

module.compute.upcloud_server.this: Destroying... [id=00de3314-f4d7-4410-b85d-6fb958b35169]

module.compute.upcloud_server.this: Still destroying... [id=00de3314-f4d7-4410-b85d-6fb958b35169, 10s elapsed]

module.compute.upcloud_server.this: Destruction complete after 10s

 

Destroy complete! Resources: 1 destroyed.
$ terraform destroy
 
module.compute.upcloud_server.this: Refreshing state... [id=00de3314-f4d7-4410-b85d-6fb958b35169]
 
 
 
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
 
 - destroy
 
 
 
Terraform will perform the following actions:
 
 
 
 # module.compute.upcloud_server.this will be destroyed
 
 - resource "upcloud_server" "this" {
 
     - cpu       = 1 -> null
 
     - firewall  = false -> null
 
     - hostname  = "project-x-dev-my-server" -> null
 
     - id        = "00de3314-f4d7-4410-b85d-6fb958b35169" -> null
 
     - mem       = 1024 -> null
 
     - metadata  = true -> null
 
     - plan      = "1xCPU-1GB" -> null
 
     - user_data = <<-EOT
 
             #!/bin/bash
 
             echo "Hello World!" >> /root/hello.txt
 
       EOT -> null
 
     - zone      = "fi-hel1" -> null
 
 
 
     - login {
 
         - create_password   = false -> null
 
         - keys              = [
 
             - <<-EOT
 
                   ssh-rsa AAAAB3NzaC1FGMEDFXv...
 
               EOT,
 
           ] -> null
 
         - password_delivery = "none" -> null
 
         - user              = "terraform" -> null
 
       }
 
 
 
     - network_interface {
 
         - bootable            = false -> null
 
         - ip_address          = "94.237.38.105" -> null
 
         - ip_address_family   = "IPv4" -> null
 
         - ip_address_floating = false -> null
 
         - mac_address         = "5e:9f:e9:d3:53:a0" -> null
 
         - network             = "03000000-0000-4000-8035-000000000000" -> null
 
         - source_ip_filtering = true -> null
 
         - type                = "public" -> null
 
       }
 
 
 
     - template {
 
         - address                  = "virtio" -> null
 
         - delete_autoresize_backup = false -> null
 
         - filesystem_autoresize    = false -> null
 
         - id                       = "012bc348-8c0b-4aa9-a975-1e6857c9c9a5" -> null
 
         - size                     = 25 -> null
 
         - storage                  = "Ubuntu Server 20.04 LTS (Focal Fossa)" -> null
 
         - tier                     = "maxiops" -> null
 
         - title                    = "terraform-project-x-dev-my-server-disk" -> null
 
       }
 
   }
 
 
 
Plan: 0 to add, 0 to change, 1 to destroy.
 
 
 
Changes to Outputs:
 
 - server_public_ip = "94.237.38.105" -> null
 
 
 
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
 
 
 
module.compute.upcloud_server.this: Destroying... [id=00de3314-f4d7-4410-b85d-6fb958b35169]
 
module.compute.upcloud_server.this: Still destroying... [id=00de3314-f4d7-4410-b85d-6fb958b35169, 10s elapsed]
 
module.compute.upcloud_server.this: Destruction complete after 10s
 
 
 
Destroy complete! Resources: 1 destroyed.

More resources like this one