BLOG

Minimise effort and speed up IaC code development with sharable Terraform modules in AWS 

Introduction 

How would you feel if I told you that you could easily create something like AWS Service Catalog, but for Terraform code? Yes, I’m talking about sharing reusable infrastructure code (IaC) in Terraform across multiple teams and projects in the entire organisation encapsulated within the AWS ecosystem without using Terraform registry or any other service outside AWS. 

I encountered this challenge recently while working on a project, and I believe the solution I created is worth sharing. Let me explain why. 

With the increase of cloud adoption  more and more infrastructure is provisioned as code. Of course, there are numerous ways to create your infrastructure as code. It’s not a secret, however, that the open-design Hashicorp Terraform is a proven leader due to its multi-cloud, multi-platform provisioning that you can even use for your custom platform solution.  

The IaC naturally comes with the question, “How do I organise my code?”. When the infrastructure is simple, this is a no-brainer. However, when the whole thing grows in complexity, you may start racking your brain about the best way to do that – I know I have. The most logical approach would be to adopt development practices of code organisation and management. It would lead you naturally to the “DRY” principle of software development, and you will inevitably notice that you have inadvertently used parts of your code in multiple places. A logical solution to this problem would be to organise the code into smaller components and use those as building blocks of your infrastructure. These building blocks can also be sharable across projects, which would speed up your development. In Terraform, these building blocks are called modules. 

Modules in Terraform 

Modules in Terraform are containers of multiple resources that go together. There are plenty of publicly available modules in the Terraform public registry that you can use – and on top of that, you can create modules that fit your own bespoke needs. 

Various questions may arise when you start working with Terraform, like “How do I decide what resources a module should contain?” or “How do I break down my infrastructure? What should I wrap in a module and what should remain in the implementation?”. 

Here are some leading principles I follow which will help in answering these questions: 

  • keep it as simple as possible 
  • don’t try to implement all possible options 
  • create your module around the use cases you want to implement 
  • keep your module implementation agnostic 
  • always store your module in a separate repository  

Sharing modules 

According to Terraform documentation, there are many ways to share a module and use it multiple times in one or several projects. Using the ‘source’ argument of the ‘module’ block, you can specify the source Terraform will use to download your module. The downloading process happens during the terraform init‘ operation. The available options boil down to public and private sources. 

Another key consideration to keep in mind is that the module can evolve, which means it may have different versions. 

The objective of this article is to demonstrate how we can stay encapsulated in AWS ecoSystem – we want to stay within the AWS account(s) and not go out into the public realm. This limitation leaves us with only four options: 

  • Local paths 
  • Git (means CodeCommit in our case) 
  • S3 buckets 

Local paths 

Utilising local paths means that the modules are part of your project repository. You can invoke them as many times as needed. 

module “vpc” { 

 source = “./modules/vpc” 

The main disadvantage of this is that you can’t share them with the repository or project. 

Git repositories 

With this approach, you store your modules in separate Git repositories. Then you can invoke them using HTTPS or ssh URL as seen in the examples below: 

module “vpc” { 

  source = “git::https://example.com/vpc.git” 

or 

module “storage” { 

  source = “git::ssh://username@example.com/storage.git” 

In both cases, you need to provide authentication to access the remote repository. 

The main disadvantages of this approach are: 

  • Inadequate speed – pulling the module is slow if you compare it to a binary (zip file from S3 bucket or URL) download. 
  • Lack of separation of concerns – development and usage of the module happens in the same place, i.e., the Git repository. 
  • Mutability – the module content can change unexpectedly. Whether you use tags to label a particular release or release branches to distinguish between different versions, they can intentionally or inadvertently change. 

S3 buckets 

Another way to source a Terraform module is when it is stored in an S3 bucket – you archive your module and upload the zip file into the S3 bucket, then specify the S3 object in your Terraform code, as seen in the example below. 

module “vpc” { 

  source = “s3::https://s3-eu-west-2.amazonaws.com/terraform-sources/modules/vpc_0.1.0.zip” 

All the disadvantages in the Git repository approach would be considered as advantages in this case.  

  • Downloading a binary file (zip) is so much faster than cloning a Git repository. 
  • The development and consumption of the module are separated. 
  • The module can be made immutable due to the Amazon S3 feature that allows you to store objects using a “write once, read many” (WORM) model. 
  • Last, last but not least, S3 is a native AWS service, an integral part of its ecosystem, which gives you excellent integration, communication and sharing. However, you might need to consider how to provide access to the other AWS services that will use this S3 bucket, like CodeBuild, for example. 

    I can almost hear your thoughts at this point: “So, if utiliszing an S3 bucket is the best option in my case, then how should I organisze my modules in an S3 bucket? Do I need multiple S3 buckets, etc.?” 

    Well, it depends on your particular use case. The easiest method is a single S3 bucket and utiliszing the S3 object key to structure your module.  

    Here is an example: 

    Minimise effort and speed up IaC code development with sharable Terraform modules in AWS 

    Module release process 

    Let us consider the next steps. You will now need to streamline and organisze your process, including releases of new versions of all the modules. Of course, the best way is to automate the validation and release of new versions. 

    Each Terraform module code or application infrastructure code lives in a dedicated CodeCommit repository. We create a Lambda function that will be invoked every time a new version is merged into the main branch The Lambda function will identify the correct repository and call a CodeBuild project by injecting event information about the CodeCommit repository, commit ID and branch. In the CodeBuild project, we can set linters, scanners and validators to inspect the code before pushing the new version – tools like terraform fmt, tflint, tfsec, checkov, etc. After all the checks pass, the code is zipped and uploaded into the S3 bucket with a proper S3 Object key reflecting the module name and version. In addition, we can also configure a notification in case of failure or success and have this notification published in an SNS topic. 

    Working with modules stored in an S3 bucket 

    Having already described how we will develop, validate, publish and share Terraform modules, we can proceed with sample projects for two applications. Let’s call them App A and App B. 

    Let’s say that our applications reside in two different AWS accounts and our terraform code reside in a separate account where we will create resources that are shared with the other accounts There is a CodePipeline that deploys the infrastructure of the application. It operates in the application account but pulls the infrastructure code from the account with shared resources. We can configure access to the shared resources like the S3 bucket, which contains our Terraform modules, for CodePipeline and CodeBuild in the application account to pull the infrastructure code. The AWS-native way is by using IAM Roles and Policies. In the application account, we should create roles for CodePipeline and CodeBuild and attach policies that allow them access to the S3 bucket. 

    We would also need to create an S3 bucket policy allowing access for these roles.  

    Below is an example of such a policy: 

    {
    “Version”: “2012-10-17”,
    “Statement”: [
    {
    “Sid”: “AllowCodeBuildPublishing”,
    “Effect”: “Allow”,
    “Principal”: {
    “AWS”: “arn:aws:iam::${account-id}:role/${codebuild-publish-role-name}”
    },
    “Action”: “s3:*”,
    “Resource”: [
    “arn:aws:s3:::terraform-sources”,
    “arn:aws:s3:::terraform-sources/*”
    ]
    },
    {
    “Sid”: “AllowDownloadInCodePipelines”,
    “Effect”: “Allow”,
    “Principal”: {
    “AWS”: [ 

             “arn:aws:iam::*:role/${codepipeline-deploy-role-name}”, 

             “arn:aws:iam::*:role/${codebuild-deploy-role-name}” 

           ]
    },
    “Action”: “s3:Get*”,
    “Resource”: [
    “arn:aws:s3:::terraform-sources”,
    “arn:aws:s3:::terraform-sources/*”
    ],
    “Condition”: {
    “StringEquals”: {
    “aws:PrincipalOrgID”: “${organization-id}”
    }
    }
    }
    ]

    There are five variables to be replaced in this policy. 

    • account-id – the ID of the AWS Account where we create the shared resources 
    • codebuild-publish-role-name – the IAM role name attached to the CodeBuild projects that publish the modules 
    • codepipeline-deploy-role-name – the IAM role name attached to the CodePipeline that deploys the application infrastructure 
    • codebuild-deploy-role-name – the IAM role name attached to the CodeBuild projects that  deploy the application infrastructure 
    • organization-id – the ID of the AWS Organization if the accounts are member accounts. 

    This is a dynamic policy that relies on having the Role with the same name in each application account. This is why we put “*” in the role’s ARN. We also add a condition to limit the accounts to the ones that are members of the AWS Organization. You can set the explicit roles’ ARNs and manually update the policy when it is needed. Depending on your use case but comply with the “least privileges” principle. 

    Everything we discussed up to this point, I have presented in the following diagram: 

    Minimise effort and speed up IaC code development with sharable Terraform modules in AWS 

    Let me share a final tip before I wrap up the topic of enhancing collaboration through Terraform. Usually, when you write your infrastructure project, it is helpful to have your Terraform modules’ code available locally along with your project code. 

    The first approach that comes to mind is to download the modules manually from the S3 bucket and unzip them in a folder. But there is an automated way to do that by using Terraform. The command is: 

    terraform get 

    The command will download all the modules in your project and place them locally under the .terraform/modules folder. The disadvantage of this approach is that it will download one module as many times as you invoke it in your project. However, considering the small size of the module code, this is not a big issue. You will also notice that the folder name differs from the module name. Instead, module blocks provide the names for the respective folders. Let’s say your module block looks like this: 

    module “main_vpc” { 

      source = “s3::https://s3-eu-west-2.amazonaws.com/terraform-modules/vpc_0.1.0.zip” 

    and 

    module “app_vpc” { 

      source = “s3::https://s3-eu-west-2.amazonaws.com/terraform-modules/vpc_0.1.0.zip” 

    The folders you will get will be as follows: 

    .terraform/modules/main_vpc 

    .terraform/modules/app_vpc 

    Regardless of their names, both folders will contain the same code, as they invoke the same source module from the S3 bucket. 

    Modules in Package 

    I wish to go the extra mile and give you an example of using modules in packages using the S3 bucket for module source. 

    This technic allows you to address a sub-folder in your module by appending a double slash and then specifying the relative path to the folder as you see in the following example. 

    module “vpc” { 

      source = “s3::https://s3-eu-west-2.amazonaws.com/terraform-sources/modules/vpc_0.1.0.zip//modules/application” 

    Terraform will download the VPC module then instead of using the top folder it will switch into modules/application and use it from there. 

    You may think of situations you will need to implement these packages. Let me give you one handy application of modules in a package. Instead of making your module complex by implementing multiple use cases, you can create a generic module in the top folder. Then under the sub-folder “modules” for example, you can implement each use case in a separate folder. Your use-case implementation should invoke the generic module with the case-specific arguments and add extra resources applicable for this use case only. This type of code structure is easier to maintain and extend. An excellent example of modules in package implementation I would recommend is the Security Group module in the Terraform Registry. 

    Conclusion 

    So here you have it – a simple, reliable solution for sharing Terraform reusable code through Terraform modules across multiple projects and accounts within AWS. It can live entirely in the AWS ecosystem. Moreover, it requires cloud-native access management that empowers users to embed even more sophisticated access to modules based on an S3 object key prefix or suffix.  

    I hope this has been useful and you will try it out soon. 

    References