Archive for May, 2017

May 7 8 Automating Cloud infrastructure with Terraform

When you start using cloud hosting solutions like Amazon Web Services, Microsoft Azure or Rackspace Cloud, it doesn't take long to feel overwhelmed by the choice and abundance of features of the platforms. Even worse, the initial setup of your applications or Web sites on a cloud platform can be very cumbersome; it involves a lot of clicking, configuring and discovering how the different parts fit together.

With tools like Terraform, building your infrastructure becomes a whole lot easier and manageable. Essentially, you are writing down a recipe for your infrastructure: Terraform allows system administrators to sit down and script their whole infrastructure stack, and connect the different parts together, just like assigning a variable in a programming language. Instead, with Terraform, you're assigning a load balancer's backend hosts to a list of servers, for example.

In this tutorial I'll walk you through a configuration example of how to set up a complete load balanced infrastructure with Terraform, and in the end you can download all the files and modify it to your own needs. I'll also talk a little about where you can go from here if you want to go further with Terraform.

You can download all the files needed for this how-to on Github.

Getting up and running

To start using Terraform, you'll need to install it. It's available as a single binary for most platforms, so download the zip file and place it somewhere in your PATH, like /usr/local/bin. Terraform runs completely on the command-line, so you'll need a little experience executing commands on the terminal.


A core part of Terraform is the variables file,, which is automatically included due to the file name. It's a place where you can define the hard dependencies for your setup, and in this case we have two:

  1. a path to a SSH public key file,
  2. the name of the AWS region we wish to create our servers in.

Both of these variables have defaults, so Terraform won't ask you to define them when running the planning step which we'll get to in a minute.

Create a folder somewhere on your harddrive, create a new file called, and add the following:

[pastacode lang="bash" manual="" message="" highlight="" provider="manual"/]

Main file

Terraform's main entrypoint is a file called, which you'll need to create. Add the following 3 lines:

[pastacode lang="bash" manual="provider%20%22aws%22%20%7B%0A%20%20region%20%3D%20%22%24%7Bvar.aws_region%7D%22%0A%7D" message="" highlight="" provider="manual"/]

This clause defines the provider. Terraform comes bundled with functionality for some providers, like Amazon Web Services which we're using in this example. One of the things you can configure it with is the default region, and we're getting that from the variables file we just created. Terraform looks for a file and includes it automatically. You can also configure AWS in other ways, like explicitly adding an AWS Access Key and Secret Key, but in this example we'll add those as environment variables. We'll also get to those later.


Next we'll start adding some actual infrastructure, in Terraform parlance that's called a resource:

[pastacode lang="bash" manual="resource%20%22aws_vpc%22%20%22vpc_main%22%20%7B%0A%20%20cidr_block%20%3D%20%2210.0.0.0%2F16%22%0A%20%20%0A%20%20enable_dns_support%20" message="Network setup" highlight="" provider="manual"/]

To contain our setup, an AWS Virtual Private Cloud is created and configured with an internal IP range, as well as DNS support and a name. Next to the resource clause is aws_vpc, which is the resource we're creating. After that is the identifier, vpc_main, which is how we'll refer to it later.

We're also creating a gateway, a route and two subnets: one for public internet-facing services like the load balancers, and a private subnet that don't need incoming network access.

As you can see, different parts are neatly interlinked by referencing them like variables.

Trying it out

At this point, we can start testing our setup. You'll have two files in a folder, and with the content that was just listed. Now it's time to actually create it in AWS.

To start, enter your AWS Access Keys as environment variables in the console, simply type the following two lines:

export AWS_SECRET_ACCESS_KEY="Your secret key"

Next, we'll create the Terraform plan file. Terraform will, with your AWS credentials, check out the status of the different resources you've defined, like the VPC and the Gateway. Since it's the first time you're running it, Terraform will instill everything for creation in the resulting plan file. Just running the plan command won't touch or create anything in AWS.

terraform plan -o terraform.plan

You'll see an overview of the resources to be created, and with the -o terraform.plan argument, the plan is saved to a file, ready for execution with apply.

terraform apply terraform.plan

Executing this command will make Terraform start running commands on AWS to create the resources. As they run, you'll see the results. If there's any errors, for example you already created a VPC with the same name before, you'll get an error, and Terraform will stop.

After running apply, you'll also see a new file in your project folder: terraform.tfstate – a cache file that maps your resources to the actual ones on Amazon. You should commit this file to git if you want to version control your Terraform project.

So now Terraform knows that your resources were created on Amazon. They were created with the AWS API, and the IDs of the different resources are saved in the tfstate file – running terraform plan again will result in nothing – there's nothing new to create.

If you change your file, like changing the VPC subnet to instead of, Terraform will figure out the necessary changes to carry out in order to to update the resources. That may result in your resources (and their dependents) being destroyed and re-created.

More resources

Having learnt a little about how Terraform works, let's go ahead and add some more things to our project.

We'll add 2 security groups, which we'll use to limit network access to our servers, and open up for public load balancers using the AWS ELB service.

[pastacode lang="bash" manual="%23%20A%20security%20group%20for%20the%20ELB%20so%20it%20is%20accessible%20via%20the%20web%0Aresou" message="" highlight="" provider="manual"/]

Our elb security group is only reachable from port 80 and 443, HTTP and HTTPS, while the default one only has public access on port 22, SSH. It also allows access from the whole VPC (including public facing load balancers) on port 80, as well as full access from other servers. Both allow all outgoing traffic.

After the ELBs, we need to define a public key which is placed on the instances we create later. Here, we use the pre-defined variable to specify the path on the local filesystem.

[pastacode lang="bash" manual="resource%20%22aws_key_pair%22%20%22auth%22%20%7B%0A%20%20key_name%20%20%20%3D%20%22default%22%0A%20%20public_key%20%3D%20%22%24%7Bfile(var.public_key_path)%7D%22%0A%7D" message="" highlight="" provider="manual"/]


You probably thought that there was a lot of duplicate code in those two security groups, and you're right. To combat that, Terraform provides custom modules, which is basically like including files.

Since we need to configure quite a few things in our EC2 instances, but the things we configure are almost always the same across them, we'll create a module for our instances. Do do that, create a new folder called instance.

In the instance folder, create 3 new files:

[pastacode lang="bash" manual="variable%20%22private_key_path%22%20%7B%0A%20%20description%20%3D%20%22Enter%20the%20path%20to%20the%20SSH%20Private%20Key%20to%20run%20provisioner.%22%0A%20%20default%20%3D%20%22~%2F.ssh%2Fid_rsa%22%0A%7D%0A%0Avariable%20%22aws_amis%22%20%7B%0A%20%20default%20%3D%20%7B%0A%20%20%20%20eu-central-1%20%3D%20%22ami-060cde69%22%0A%20%20%7D%0A%7D%0A%0Avariable%20%22disk_size%22%20%7B%0A%20%20default%20%3D%208%0A%7D%0A%0Avariable%20%22count%22%20%7B%0A%20%20default%20%3D%201%0A%7D%0A%0Avariable%20%22group_name%22%20%7B%0A%20%20description%20%3D%20%22Group%20name%20becomes%20the%20base%20of%20the%20hostname%20of%20the%20instance%22%0A%7D%0A%0Avariable%20%22aws_region%22%20%7B%0A%20%20description%20%3D%20%22AWS%20region%20to%20launch%20servers.%22%0A%20%20default%20%20%20%20%20%3D%20%22eu-central-1%22%0A%7D%0A%0Avariable%20%22instance_type%22%20%7B%0A%20%20description%20%3D%20%22AWS%20region%20to%20launch%20servers.%22%0A%20%20default%20%20%20%20%20%3D%20%22t2.small%22%0A%7D%0A%0Avariable%20%22subnet_id%22%20%7B%0A%20%20description%20%3D%20%22ID%20of%20the%20AWS%20VPC%20subnet%20to%20use%22%0A%7D%0A%0Avariable%20%22key_pair_id%22%20%7B%0A%20%20description%20%3D%20%22ID%20of%20the%20keypair%20to%20use%20for%20SSH%22%0A%7D%0A%0Avariable%20%22security_group_id%22%20%7B%0A%20%20description%20%3D%20%22ID%20of%20the%20VPC%20security%20group%20to%20use%20for%20network%22%0A%7D" message="instance/" highlight="" provider="manual"/]

[pastacode lang="bash" manual="resource%20%22aws_instance%22%20%22instance%22%20%7B%0A%20%20count%20%3D%20%22%24%7Bvar.count%7D%22%0A%0A%20%20instance_type%20%20%20%20%20%20%20%20%20%20%3D%20%22%24%7Bvar.instance_type%7D%22%0A%20%20ami%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3D%20%22%24%7Blookup(var.aws_amis%2C%20var.aws_region)%7D%22%0A%20%20key_name%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3D%20%22%24%7Bvar.key_pair_id%7D%22%0A%20%20vpc_security_group_ids%20%3D%20%5B%22%24%7Bvar.security_group_id%7D%22%5D%0A%20%20subnet_id%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3D%20%22%24%7Bvar.subnet_id%7D%22%0A%20%20%0A%20%20root_block_device%20%7B%0A%20%20%20%20%20%20volume_size%20%3D%20%22%24%7Bvar.disk_size%7D%22%0A%20%20%7D%0A%20%20%0A%20%20tags%20%7B%0A%20%20%20%20%20%20Name%20%3D%20%22%24%7Bformat(%22%25s%2502d%22%2C%20var.group_name%2C%20count.index%20%2B%201)%7D%22%20%23%20-%3E%20%22backend02%22%0A%20%20%20%20%20%20Group%20%3D%20%22%24%7Bvar.group_name%7D%22%0A%20%20%7D%0A%20%20%0A%20%20lifecycle%20%7B%0A%20%20%20%20create_before_destroy%20%3D%20true%0A%20%20%7D%0A%20%20%0A%20%20%23%20Provisioning%0A%20%20%0A%20%20connection%20%7B%0A%20%20%20%20user%20%3D%20%22ubuntu%22%0A%20%20%20%20private_key%20%3D%20%22%24%7Bfile(var.private_key_path)%7D%22%0A%20%20%7D%0A%0A%20%20provisioner%20%22remote-exec%22%20%7B%0A%20%20%20%20inline%20%3D%20%5B%0A%20%20%20%20%20%20%22sudo%20apt-get%20-y%20update%22%2C%0A%20%20%20%20%5D%0A%20%20%7D%0A%7D" message="instance/" highlight="" provider="manual"/]

[pastacode lang="bash" manual="%23%20Used%20for%20configuring%20ELBs.%0Aoutput%20%22instance_ids%22%20%7B%0A%20%20%20%20value%20%3D%20%5B%22%24%7Baws_instance.instance.*.id%7D%22%5D%0A%7D" message="instance/" highlight="" provider="manual"/]

In the variables file, we have a few things worth mentioning:

  • a default path to the private key of the public key – we'll need the private key for connecting via SSH and launching the provisioner,
  • we define a list of AMIs, or more specifically a map. Here, since we're only focusing on Amazon's EU Central 1 region, we've only defined an AMI for that region (It's Ubuntu 16.04 LTS). You need to go browse Amazon's AMI library if you use another region, or you want to use another operating system,
  • some defaults are defined, like the count of instances, disk size, etc. These can be overwritten when invoking the module,
  • some variables don't have defaults – weirdly, Terraform doesn't let you automatically inherit variables, which is why I've chosen to place the private key path here. Otherwise I'd have to pass the main Terraform variable to every module.

The output file allows the module to export some properties – you have to explicitly define outputs for everything you want to reference later. The only thing I have to reference is the actual instance IDs (for use in the ELBs), so that's the only output.

Using the Tags array, we can add some info to our instances. I'm using one of Terraforms built-in functions, format, to generate a friendly hostname based on the group name and a 1-indexed number. Also, the provisioner clause is a little bare. Instead, one would typically reference an Chef or Ansible playbook, or just run some commands to set up your environment and bootstrap your application.

Back in your main Terraform file,, you can now start referencing your AWS EC2 Instance module:

[pastacode lang="bash" manual="" message="" highlight="" provider="manual"/]

Instead of resource, the modules are referenced using the module clause. All modules have to have a source reference, pertaining to the directory of where the module's file is located.

Again, since modules can't automatically inherit or reference parent resources, we'll have to explicitly pass the subnet, key pair and security groups to the module.

This example consists of 9 instances:

  • 2x backend,
  • 2x backend workers,
  • 2x frontend servers,
  • 3x MySQL servers.

Load balancers

To finish our terraform file, we add the remaining component: load balancers.

[pastacode lang="bash" manual="" message="" highlight="" provider="manual"/]

The load balancers provide the entrypoints for our application. One thing to note here is how the instances are referenced[Footnote 1].

Main output file

To put a cherry on top, we'll create an output file for our main project, Again, due to the filename, Terraform will automatically pick it up.

[pastacode lang="bash" manual="%23%20Public%20Load%20Balancers%0A%0Aoutput%20%22api_address%22%20%7B%0A%20%20value%20%3D%20%22%24%7Baws_elb.backend.dns_name%7D%22%0A%7D%0A%0Aoutput%20%22frontend_address%22%20%7B%0A%20%20value%20%3D%20%22%24%7Baws_elb.frontend.dns_name%7D%22%0A%7D%0A%0A%23%20Private%20Load%20Balancers%0A%0Aoutput%20%22galera_address%22%20%7B%0A%20%20value%20%3D%20%22%24%7Baws_elb.db_mysql.dns_name%7D%22%0A%7D" message="" highlight="" provider="manual"/]

This will display the hostnames of our ELBs in a friendly format after running terraform apply, which is handy for copying into a configuration file or your browser.

You can now run terraform plan again like before, but since you're using modules, you'll have to run terraform get first to include them.

Then you can see that it will create the remaining infrastructure when you do terraform apply.

You can clone, fork or download the full project over on Github.

Next steps

Where can you go from here? I have a couple ideas:

  • Move your DNS to Amazon Route53 and automate your DNS entries with the outputs from the ELBs.
  • In addition to Route53, see what other AWS services you can provision using Terraform, like S3 buckets, autoscaling groups, AMIs, IAM groups/policies...
  • Further use modules to simplify your main file, for example by nesting multiple resources in one file. You could, for example, have all your network setup in a single module to make the base file more concise.
  • Integrate with provisioning software like Ansible, using their EC2 inventory to easily provision new instances.


  1. Yes, the instance IDs are inside a string, which is how all resources or modules are references, even though they technically are arrays and (in my opinion) shouldn't be encapsulated in a string. But that's how it is.