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 }

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.  

 

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)

 

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"  

»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"  

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. 

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")    ]  } }

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 }  

versions.tf:

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 }

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  }) }

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"  } }

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.

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.

»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 }

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"

»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.

More resources like this one

  • 3/15/2023
  • Presentation

Advanced Terraform techniques

  • 2/3/2023
  • Case Study

Automating Multi-Cloud, Multi-Region Vault for Teams and Landing Zones

  • 2/1/2023
  • Case Study

Should My Team Really Need to Know Terraform?

  • 1/20/2023
  • Case Study

Packaging security in Terraform modules