Virtual Event
Join us for the next HashiConf Digital October 12-15, 2020 Register for Free

Organize Configuration

Build a Module

In the last guide, you used modules from the Terraform Registry to create a VPC and EC2 instance in AWS. While using existing Terraform modules correctly is an important skill, every Terraform practitioner will also benefit from learning how to create modules. In fact, we recommend that every Terraform configuration be created with the assumption that it may be used as a module, because doing so will help you design your configurations to be flexible, reusable, and composable.

As you may already know, Terraform treats every configuration as a module. When you run terraform commands, or use Terraform Cloud or Terraform Enterprise to remotely run Terraform, the target directory containing Terraform configuration is treated as the root module.

In this guide, you will create a module to manage AWS S3 buckets used to host static websites.

»Module structure

Terraform treats any local directory referenced in the source argument of a module block as a module. A typical file structure for a new module is:

$ tree minimal-module/
.
├── LICENSE
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf

None of these files are required, or have any special meaning to Terraform when it uses your module. You can create a module with a single .tf file, or use any other file structure you like.

Each of these files serves a purpose:

  • LICENSE will contain the license under which your module will be distributed. When you share your module, the LICENSE file will let people using it know the terms under which it has been made available. Terraform itself does not use this file.
  • README.md will contain documentation describing how to use your module, in markdown format. Terraform does not use this file, but services like the Terraform Registry and GitHub will display the contents of this file to people who visit your module's Terraform Registry or GitHub page.
  • main.tf will contain the main set of configuration for your module. You can also create other configuration files and organize them however makes sense for your project.
  • variables.tf will contain the variable definitions for your module. When your module is used by others, the variables will be configured as arguments in the module block. Since all Terraform values must be defined, any variables that are not given a default value will become required arguments. Variables with default values can also be provided as module arguments, overriding the default value.
  • outputs.tf will contain the ouput definitions for your module. Module outputs are made available to the configuration using the module, so they are often used to pass information about the parts of your infrastructure defined by the module to other parts of your configuration.

There are also some other files to be aware of, and ensure that you don't distribute them as part of your module:

  • terraform.tfstate and terraform.tfstate.backup: These files contain your Terraform state, and are how Terraform keeps track of the relationship between your configuration and the infrastructure provisioned by it.
  • .terraform: This directory contains the modules and plugins used to provision your infrastructure. These files are specific to a specific instance of Terraform when provisioning infrastructure, not the configuration of the infrastructure defined in .tf files.
  • *.tfvars: Since module input variables are set via arguments to the module block in your configuration, you don't need to distribute any *.tfvars files with your module, unless you are also using it as a standalone Terraform configuration.

If you are tracking changes to your module in a version control system, such as git, you will want to configure your version control system to ignore these files. For an example, see this .gitignore file from GitHub.

»Create a module

This guide will use the configuration created in the using modules guide as a starting point. You can either continue working on that configuration in your local directory, or use the following commands to clone this GitHub repository.

Clone the GitHub repository.

$ git clone https://github.com/hashicorp/learn-terraform-modules.git

Change into that directory in your terminal.

$ cd learn-terraform-modules

Start from the website-complete-example tag.

$ git checkout website-complete-example

If you have cloned the GitHub repository, you will also want to ensure that Terraform has downloaded all the necessary providers and modules by initializing it.

$ terraform init
Initializing modules...
Downloading terraform-aws-modules/ec2-instance/aws 2.12.0 for ec2_instances...
- ec2_instances in .terraform/modules/ec2_instances/terraform-aws-modules-terraform-aws-ec2-instance-ed6dcd9
Downloading terraform-aws-modules/vpc/aws 2.21.0 for vpc...
- vpc in .terraform/modules/vpc/terraform-aws-modules-terraform-aws-vpc-2417f60
- website_s3_bucket in modules/aws-s3-static-website-bucket

Initializing the backend...


Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "aws" (hashicorp/aws) 2.44.0...

# ...

In this guide, you will create a local submodule within your exsiting configuration that uses the s3 bucket resource from the AWS provider.

If you didn't clone the example repository, you'll need to create the directory for your module. Inside your existing configuration directory, create a directory called modules, with a directory called aws-s3-static-website-bucket inside of it. For example, on Linux or Mac systems, run:

$ mkdir -p modules/aws-s3-static-website-bucket

After creating these directories, your configuration's directory structure will look like this:

$ tree
.
├── LICENSE
├── README.md
├── main.tf
├── modules
│   └── aws-s3-static-website-bucket
├── outputs.tf
├── terraform.tfstate
├── terraform.tfstate.backup
└── variables.tf

2 directories, 8 files

If you have cloned the GitHub repository, the tfstate files won't appear until you run a terraform apply command.

Hosting a static website with S3 is a fairly common use case. While it isn't too difficult to figure out the correct configuration to provision a bucket this way, encapsulating this configuration within a module will provide your users with a quick and easy way create buckets they can use to host static websites that adhere to best practices. Another benefit of using a module is that the module name can describe exactly what buckets created with it are for. In this example, the aws-s3-static-website-bucket module creates s3 buckets that host static websites.

»Create a README.md and LICENSE

If you have cloned the GitHub repository, it will include README.md and LICENSE files. These files are not used by Terraform at all. They are included in this example to demonstrate best practice. If you want, you can create them as follows.

Inside the aws-s3-static-website-bucket directory, create a file called README.md with the following content.

# AWS S3 static website bucket

This module provisions AWS S3 buckets configured for static website hosting.

Choosing the correct license for your modules is out of the scope of this guide. This guide will use the Apache 2.0 open source license.

Create another file called LICENSE with the following content.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Neither of these files is required or used by Terraform. Having them is a best practice for modules that may one day be shared with others.

»Add module configuration

You will work with three Terraform configuration files inside the aws-s3-static-website-bucket directory: main.tf, variables.tf, and outputs.tf.

If you checked out the git repository, those files will already exist. Otherwise, you can create these empty files now. After you do so, your module directory structure will look like this:

$ tree modules/
modules/
└── aws-s3-static-website-bucket
    ├── LICENSE
    ├── README.md
    ├── main.tf
    ├── outputs.tf
    └── variables.tf

Add an S3 bucket resource to main.tf inside the modules/aws-s3-static-website-bucket directory:

resource "aws_s3_bucket" "s3_bucket" {
  bucket = var.bucket_name

  acl    = "public-read"
  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::${var.bucket_name}/*"
            ]
        }
    ]
}
EOF

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  tags = var.tags
}

This configuration creates a public S3 bucket hosing a website with an index page and an error page.

You will notice that there is no provider block in this configuration. When Terraform processes a module block, it will inherit the provider from the enclosing configuration. Because of this, we recommend that you do not include provider blocks in modules.

Just like the root module of your configuration, modules will define and use variables.

Define the following variables in variables.tf inside the modules/aws-s3-static-website-bucket directory:

variable "bucket_name" {
  description = "Name of the s3 bucket. Must be unique."
  type = string
}

variable "tags" {
  description = "Tags to set on the bucket."
  type = map(string)
  default = {}
}

Variables within modules work almost exactly the same way that they do for the root module. When you run a Terraform command on your root configuration, there are various ways to set variable values, such as passing them on the commandline, or with a .tfvars file. When using a module, variables are set by passing arguments to the module in your configuration. You will set some of these variables when calling this module from your root module's main.tf.

Variables defined in modules that aren't given a default value are required, and so must be set whenever the module is used.

When creating a module, consider which resource arguments to expose to module end users as input variables. For example, you might decide to expose the index and error documents to end users of this module as variables, but not define a variable to set the ACL , since to host a website your bucket will need the ACL to be set to "public-read".

You should also consider which values to add as outputs, since outputs are the only supported way for users to get information about resources configured by the module.

Add outputs to your module in the outputs.tf file inside the modules/aws-s3-static-website-bucket directory:

# Output variable definitions

output "arn" {
  description = "ARN of the bucket"
  value       = aws_s3_bucket.s3_bucket.arn
}

output "name" {
  description = "Name (id) of the bucket"
  value       = aws_s3_bucket.s3_bucket.id
}

output "website_endpoint" {
  description = "Domain name of the bucket"
  value       = aws_s3_bucket.s3_bucket.website_endpoint
}

Like variables, outputs in modules perform the same function as they do in the root module but are accessed in a different way. A module's outputs can be accessed as read-only attributes on the module object, which is available within the configuration that calls the module. You can reference these outputs in expressions as module.<MODULE NAME>.<OUTPUT NAME>.

Now that you have created your module, return to the main.tf in your root module and add a reference to the new module:

module "website_s3_bucket" {
  source = "./modules/aws-s3-static-website-bucket"

  bucket_name = "<UNIQUE BUCKET NAME>"

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

AWS S3 Buckets must be globally unique. Because of this, you will need to replace <UNIQUE BUCKET NAME> with a unique, valid name for an S3 bucket. Using your name and the date is usually a good way to guess a unique bucket name. For example:

  bucket_name = "robin-example-2020-01-15"

In this example, the bucket_name and tags arguments will be passed to the module, and provide values for the matching variables found in modules/aws-s3-static-website-bucket/variables.tf.

»Define outputs

Earlier, you added several outputs to the aws-s3-static-website-bucket module, making those values available to your root module configuration.

Add these values as outputs to your root module by adding the following to outputs.tf file in your root module directory (not the one in modules/aws-s3-static-website-bucket).

output "website_bucket_arn" {
  description = "ARN of the bucket"
  value       = module.website_s3_bucket.arn
}

output "website_bucket_name" {
  description = "Name (id) of the bucket"
  value       = module.website_s3_bucket.name
}

output "website_endpoint" {
  description = "Domain name of the bucket"
  value       = module.website_s3_bucket.website_endpoint
}

»Install the local module

Whenever you add a new module to a configuration, Terraform must install the module before it can be used. Both the terraform get and terraform init commands will install and update modules. The terraform init command will also initialize backends and install plugins.

Now install the module by running terraform get.

$ terraform get

Now that your new module is installed and configured, run terraform apply to provision your bucket.

$ terraform apply

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:

# ...

  # module.website_s3_bucket.aws_s3_bucket.s3_bucket will be created
  + resource "aws_s3_bucket" "s3_bucket" {
      + acceleration_status         = (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 you respond to the prompt with yes, your bucket and other resources will be provisioned.

After running terraform apply, your bucket will be created.

»Upload files to the bucket

You have now configured and used your own module to create a static website. You may want to visit this static website. Right now there is nothing inside your bucket, so there would be nothing to see if you visit the bucket's website. In order to see any content, you will need to upload objects to your bucket. You can upload the contents of the www directory found in the GitHub repository to the bucket using the AWS console, or the AWS commandline tool, for example:

$ aws s3 cp modules/aws-s3-static-website-bucket/www/ s3://$(terraform output website_bucket_name)/ --recursive
upload: modules/aws-s3-static-website-bucket/www/error.html to s3://robin-test-2020-01-15/error.html
upload: modules/aws-s3-static-website-bucket/www/index.html to s3://robin-test-2020-01-15 /index.html

The website endpoint was shown when you last ran terraform apply, or whenever you run terraform output.

Visit the website endpoint in a web browser, and you will see the website contents.

https://<YOUR BUCKET NAME>.s3-us-west-2.amazonaws.com/index.html

»Clean up the website and infrastructure

If you have uploaded files to your bucket, you will need to delete them before the bucket can be destroyed. For example, you could run:

$ aws s3 rm s3://$(terraform output website_bucket_name)/ --recursive
delete: s3://robin-test-2020-01-15/index.html
delete: s3://robin-test-2020-01-15/error.html

Once the bucket is empty, destroy your Terraform resources:

$ terraform destroy
module.vpc.aws_vpc.this[0]: Refreshing state... [id=vpc-00ff17f9d4801bdef]
module.vpc.aws_eip.nat[0]: Refreshing state... [id=eipalloc-0364049fdf8f8d33d]

# ...

Plan: 0 to add, 0 to change, 23 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

module.vpc.aws_route_table_association.private[1]: Destroying... [id=rtbassoc-01a79185940c0247b]
module.vpc.aws_route.public_internet_gateway[0]: Destroying... [id=r-rtb-0d22a7780ac9509df1080289494]

# ...

Destroy complete! Resources: 23 destroyed.

After you respond to the prompt with yes, Terraform will destroy all of the resources created by following this guide.

»Next steps

In this guide, you have learned how to:

  • Create a Terraform module
  • Use local Terraform modules in your configuration
  • Configure modules with variables
  • Use module outputs

You can read more about modules in the Terraform documentation.