<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Terraform on David Hamann</title><link>https://davidhamann.de/tags/terraform/</link><description>Recent content in Terraform on David Hamann</description><generator>Hugo</generator><language>en</language><copyright>&amp;copy; David Hamann</copyright><lastBuildDate>Wed, 21 Jan 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://davidhamann.de/tags/terraform/feed.xml" rel="self" type="application/rss+xml"/><item><title>Using the Hetzner Cloud Terraform Provider</title><link>https://davidhamann.de/2026/01/21/hetzner-cloud-terraform/</link><pubDate>Wed, 21 Jan 2026 00:00:00 +0000</pubDate><guid>https://davidhamann.de/2026/01/21/hetzner-cloud-terraform/</guid><description>&lt;p&gt;I&amp;rsquo;m currently in the process of setting up several cloud servers for a new project. The whole infrastructure will run on Hetzner Cloud and be codified.&lt;/p&gt;
&lt;p&gt;Since it&amp;rsquo;s the first time I&amp;rsquo;m using the &lt;a href="https://github.com/hetznercloud/terraform-provider-hcloud"&gt;Terraform provider for Hetzner Cloud&lt;/a&gt;, I want to write down some of the notes I took.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll focus on creating a small, simplified, self-contained example to create a server, set up a firewall, and get a public IP assigned (no private network). If you want to manage multiple stacks and/or multiple projects, it generally makes sense to create your own modules and shared configurations. This is, however, out of scope for this post and not really any different when using Hetzner Cloud vs. other providers.&lt;/p&gt;
&lt;p&gt;If you like to read a general overview of Terraform/Infrastructure as Code first, I&amp;rsquo;ve &lt;a href="https://davidhamann.de/2020/05/20/terraform-infrastructure-as-code-intro/"&gt;written a tutorial&lt;/a&gt; in 2020 which should still be mostly up-to-date.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;To tell Terraform we are going to work with the Hetzner cloud, we need to specify the relevant provider including a version number, and a token to use for interacting with the API:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;terraform {
 required_providers {
 hcloud = {
 source = &amp;#34;hetznercloud/hcloud&amp;#34;
 version = &amp;#34;~&amp;gt; 1.45&amp;#34;
 }
 }
}

provider &amp;#34;hcloud&amp;#34; {
 token = var.hcloud_token
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you&amp;rsquo;ve used Terraform before this looks just like other provider configurations. We say we want to use the &lt;code&gt;hcloud&lt;/code&gt; provider found at &lt;code&gt;hetznercloud/hcloud&lt;/code&gt; in the Terraform registry. We set the version compatibility to &lt;code&gt;~&amp;gt; 1.45&lt;/code&gt;, i.e. allowing any higher version less than &lt;code&gt;2.0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The token can be acquired through the Hetzner Cloud console (Your project -&amp;gt; Security -&amp;gt; API Tokens) and then passed in via a variable:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Create an API token" loading="lazy" src="https://davidhamann.de/images/hetzner-api-token.png"&gt;&lt;/p&gt;
&lt;p&gt;While not a requirement, a very helpful tool when setting up your Hetzner infrastructure is the &lt;a href="https://github.com/hetznercloud/cli"&gt;&lt;code&gt;hcloud&lt;/code&gt; CLI application&lt;/a&gt;. I use it mostly to look up things – for example to find the right image identifier to use for a new server:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ hcloud image list | grep ubuntu
67794396 system ubuntu-22.04 Ubuntu 22.04 x86 - 5 GB 2022-04-21 15:32:38 CEST -
103908130 system ubuntu-22.04 Ubuntu 22.04 arm - 5 GB 2023-03-20 11:10:04 CET -
161547269 system ubuntu-24.04 Ubuntu 24.04 x86 - 5 GB 2024-04-25 15:26:27 CEST -
161547270 system ubuntu-24.04 Ubuntu 24.04 arm - 5 GB 2024-04-25 15:26:51 CEST -
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Or to find the right server type:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ hcloud server-type list
ID NAME CORES CPU TYPE ARCHITECTURE MEMORY DISK STORAGE TYPE
22 cpx11 2 shared x86 2.0 GB 40 GB local
23 cpx21 3 shared x86 4.0 GB 80 GB local
24 cpx31 4 shared x86 8.0 GB 160 GB local
25 cpx41 8 shared x86 16.0 GB 240 GB local
26 cpx51 16 shared x86 32.0 GB 360 GB local
45 cax11 2 shared arm 4.0 GB 40 GB local
93 cax21 4 shared arm 8.0 GB 80 GB local
94 cax31 8 shared arm 16.0 GB 160 GB local
95 cax41 16 shared arm 32.0 GB 320 GB local
96 ccx13 2 dedicated x86 8.0 GB 80 GB local
97 ccx23 4 dedicated x86 16.0 GB 160 GB local
98 ccx33 8 dedicated x86 32.0 GB 240 GB local
99 ccx43 16 dedicated x86 64.0 GB 360 GB local
100 ccx53 32 dedicated x86 128.0 GB 600 GB local
101 ccx63 48 dedicated x86 192.0 GB 960 GB local
...
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="initializing"&gt;Initializing&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s create a new directory, save the provider config from above in a file called &lt;code&gt;main.tf&lt;/code&gt; and then initialize it:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hetznercloud/hcloud versions matching &amp;#34;~&amp;gt; 1.45&amp;#34;...
- Installing hetznercloud/hcloud v1.59.0...
- Installed hetznercloud/hcloud v1.59.0 (signed by a HashiCorp partner, key ID 5219EACB3A77198B)
Partner and community providers are signed by their developers.
If you&amp;#39;d like to know more about provider signing, you can read about it here:
https://developer.hashicorp.com/terraform/cli/plugins/signing
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 &amp;#34;terraform init&amp;#34; in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running &amp;#34;terraform plan&amp;#34; 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.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;As you see, the version &lt;code&gt;1.45&lt;/code&gt; we specified is already superseded, and we downloaded version &lt;code&gt;1.59.0&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;If you want to follow along, your directory should now look like this (or similar, depending on your architecture):&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;.
├── .terraform
│   └── providers
│   └── registry.terraform.io
│   └── hetznercloud
│   └── hcloud
│   └── 1.59.0
│   └── darwin_arm64
│   ├── CHANGELOG.md
│   ├── LICENSE
│   ├── README.md
│   └── terraform-provider-hcloud_v1.59.0
├── .terraform.lock.hcl
└── main.tf
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you prefer to build the provider yourself, please see &lt;a href="https://github.com/hetznercloud/terraform-provider-hcloud"&gt;these instructions on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="setting-up-variables-and-outputs"&gt;Setting up variables and outputs&lt;/h2&gt;
&lt;p&gt;To make it easier to experiment, let&amp;rsquo;s create a &lt;code&gt;variables.tf&lt;/code&gt; and define a few variables for attributes we might want to change along the way:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;variable &amp;#34;hcloud_token&amp;#34; {
 sensitive = true
 type = string
}

variable &amp;#34;project_name&amp;#34; {
 type = string
 description = &amp;#34;Identifier for naming resources&amp;#34;
}

variable &amp;#34;server_type&amp;#34; {
 type = string
 default = &amp;#34;cpx22&amp;#34; # check &amp;#34;hcloud server-type list&amp;#34; for a list of options
}

variable &amp;#34;server_image&amp;#34; {
 type = string
 default = &amp;#34;ubuntu-24.04&amp;#34; # check &amp;#34;hcloud image list&amp;#34; for a list of options
}

variable &amp;#34;location&amp;#34; {
 type = string
 default = &amp;#34;fsn1&amp;#34; # the data center at which you want to create the resources.
 # check &amp;#34;hcloud location list&amp;#34; for a list of options
}

variable &amp;#34;admin_ips&amp;#34; {
 type = list(string)
 description = &amp;#34;CIDR IP ranges for administrative access&amp;#34;
}

variable &amp;#34;ssh_keys&amp;#34; {
 type = list(string)
 description = &amp;#34;SSH key names or IDs&amp;#34;
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Most of the variables should be self-explanatory, but I&amp;rsquo;ve also added a few comments and descriptions to explain the meaning.&lt;/p&gt;
&lt;p&gt;To later find out the ID and IP of the server we provision, let&amp;rsquo;s also create an &lt;code&gt;outputs.tf&lt;/code&gt; file with the following content:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;output &amp;#34;server_id&amp;#34; {
 value = hcloud_server.example_server.id
}

output &amp;#34;server_ipv4&amp;#34; {
 value = hcloud_server.example_server.ipv4_address
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I&amp;rsquo;ll explain where the &lt;code&gt;hcloud_server.example_server&lt;/code&gt; comes from in the next step.&lt;/p&gt;
&lt;h2 id="setting-up-server-firewall-and-ip-address"&gt;Setting up server, firewall and IP address&lt;/h2&gt;
&lt;p&gt;Now we can finally start creating our resources. Again, the plan is to simply create a server, a few firewall rules and an IP address and then attach the resources accordingly.&lt;/p&gt;
&lt;h3 id="ip-address"&gt;IP address&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s start with the IP address by creating a new file &lt;code&gt;network.tf&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;hcloud_primary_ip&amp;#34; &amp;#34;example_ipv4&amp;#34; {
 name = &amp;#34;${var.project_name}-ipv4&amp;#34;
 type = &amp;#34;ipv4&amp;#34;
 location = var.location
 assignee_type = &amp;#34;server&amp;#34;
 auto_delete = false
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To assign an IP address to servers on Hetzner Cloud you will need to create a &lt;code&gt;hcloud_primary_ip&lt;/code&gt; resource.&lt;/p&gt;
&lt;p&gt;In the above example, we give it a name, a type (one of &lt;code&gt;ipv4&lt;/code&gt; or &lt;code&gt;ipv6&lt;/code&gt;), a data center location and a type saying what kind of resource we want to assign this IP to (&lt;code&gt;server&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;We set &lt;code&gt;auto_delete&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; such that the IP is not being deleted when the server is deleted (if you run &lt;code&gt;terraform destroy&lt;/code&gt; later this will still delete the IP as expected).&lt;/p&gt;
&lt;p&gt;You could also set &lt;code&gt;assignee_id&lt;/code&gt;, but since we want to be able to create the IP before the server is created, I&amp;rsquo;ll just set the &lt;code&gt;assignee_type&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;name&lt;/code&gt; (just a resource identifier) and &lt;code&gt;location&lt;/code&gt; (data center) make use of the variables we specified before.&lt;/p&gt;
&lt;h3 id="firewall"&gt;Firewall&lt;/h3&gt;
&lt;p&gt;On to the firewall configuration.&lt;/p&gt;
&lt;p&gt;Since this example doesn&amp;rsquo;t really have a use-case for the server in mind, we just set a few common inbound rules.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s create &lt;code&gt;firewall.tf&lt;/code&gt; and start setting up an &lt;code&gt;hcloud_firewall&lt;/code&gt; resource:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;hcloud_firewall&amp;#34; &amp;#34;example_fw&amp;#34; {
 name = &amp;#34;${var.project_name}-firewall&amp;#34;

 # ssh
 rule {
 direction = &amp;#34;in&amp;#34;
 protocol = &amp;#34;tcp&amp;#34;
 port = &amp;#34;22&amp;#34;
 source_ips = var.admin_ips
 }

 # icmp
 rule {
 direction = &amp;#34;in&amp;#34;
 protocol = &amp;#34;icmp&amp;#34;
 source_ips = var.admin_ips
 }

 # http
 rule {
 direction = &amp;#34;in&amp;#34;
 protocol = &amp;#34;tcp&amp;#34;
 port = &amp;#34;80&amp;#34;
 source_ips = [&amp;#34;0.0.0.0/0&amp;#34;]
 }

 # https
 rule {
 direction = &amp;#34;in&amp;#34;
 protocol = &amp;#34;tcp&amp;#34;
 port = &amp;#34;443&amp;#34;
 source_ips = [&amp;#34;0.0.0.0/0&amp;#34;]
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We give the firewall a name and use the &lt;code&gt;rule&lt;/code&gt; argument to create rules.&lt;/p&gt;
&lt;p&gt;Each rule must have a &lt;code&gt;direction&lt;/code&gt;, &lt;code&gt;protocol&lt;/code&gt;, &lt;code&gt;source_ips&lt;/code&gt;, and a &lt;code&gt;port&lt;/code&gt; (if it applies, i.e. for &lt;code&gt;tcp&lt;/code&gt; and &lt;code&gt;udp&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;For SSH and ICMP I limit the source IPs to the ranges we can configure via the &lt;code&gt;admin_ips&lt;/code&gt; variable. The HTTP ports I leave open for all.&lt;/p&gt;
&lt;h3 id="server"&gt;Server&lt;/h3&gt;
&lt;p&gt;Now we can finally create our server resource:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;hcloud_server&amp;#34; &amp;#34;example_server&amp;#34; {
 name = &amp;#34;${var.project_name}-server&amp;#34;
 server_type = var.server_type
 image = var.server_image
 location = var.location

 ssh_keys = var.ssh_keys

 firewall_ids = [
 hcloud_firewall.example_fw.id
 ]

 public_net {
 ipv4_enabled = true
 ipv4 = hcloud_primary_ip.example_ipv4.id
 ipv6_enabled = false
 }

 user_data = templatefile(&amp;#34;${path.module}/cloud-init.yml&amp;#34;, {
 hostname = &amp;#34;${var.project_name}-example&amp;#34;
 })

 labels = {
 project = var.project_name
 role = &amp;#34;example-server&amp;#34;
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Most of the arguments should be clear, but let&amp;rsquo;s quickly go through them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt; - Obvious&lt;/li&gt;
&lt;li&gt;&lt;code&gt;server_type&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;location&lt;/code&gt; - These define the type of server, OS and data center, and are filled with the variable values set earlier&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ssh_keys&lt;/code&gt; - Here, you can set names of SSH keys that you have set up in the Hetzner Cloud Console. They will be written into the &lt;code&gt;authorized_keys&lt;/code&gt; file of the root user during initialization. Since we also do some cloud-init setup in the next step, this wouldn&amp;rsquo;t be strictly necessary, but it&amp;rsquo;s nice to have a backup access method in case the cloud-init fails. We are setting this argument from a variable defined earlier.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;firewall_ids&lt;/code&gt; – Here, we attach the firewall resource created earlier. Since it&amp;rsquo;s a list, you can also combine firewalls (I do that for example, when I have a shared module with a few default rules and then a customer or project module with further more specific rules).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;public_net&lt;/code&gt; – In this block, we can attach the IP address we created earlier. If you don&amp;rsquo;t set this, Hetzner will automatically create a new primary IPv4 (and IPv6, if enabled) for you.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_data&lt;/code&gt; – This is where we can set &lt;a href="https://cloudinit.readthedocs.io"&gt;cloud-init&lt;/a&gt; data for initialization of the VM. We load the initialization steps from a file &lt;code&gt;cloud-init.yml&lt;/code&gt;. More on this later.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;labels&lt;/code&gt; – Just a bunch of labels you can attach to the server&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="cloud-init"&gt;cloud-init&lt;/h3&gt;
&lt;p&gt;To initialize the server with a few commands, we can use cloud-init (just like on other platforms).&lt;/p&gt;
&lt;p&gt;I usually do a bare minimum in the cloud config and then do the actual set up later on with ansible.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a sample config for completeness. However, it&amp;rsquo;s not specifically related to Hetzner Cloud:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;#cloud-config

hostname: ${hostname}

package_update: true
package_upgrade: true

packages:
 - ufw
 - python3

users:
 - name: ansible
 groups: users, admin
 lock_passwd: true
 sudo: ALL=(ALL) NOPASSWD:ALL
 shell: /bin/bash
 ssh_authorized_keys:
 - ssh-ed25519 AAAA...

write_files:
 - path: /etc/ssh/sshd_config.d/ssh.conf
 content: |
 PermitRootLogin no
 PasswordAuthentication no
 Port 22
 MaxAuthTries 2
 AuthorizedKeysFile .ssh/authorized_keys
 AllowUsers ansible

runcmd:
 - ufw allow 22/tcp comment &amp;#39;SSH&amp;#39;
 - ufw allow 80/tcp comment &amp;#39;HTTP&amp;#39;
 - ufw allow 443/tcp comment &amp;#39;HTTPS&amp;#39;
 - ufw --force enable
 - systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="creating-and-destroying-the-infrastructure"&gt;Creating and destroying the infrastructure&lt;/h2&gt;
&lt;p&gt;If you followed along, you should now have a directory with the following files:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;.
├── cloud-init.yml
├── firewall.tf
├── main.tf
├── network.tf
├── outputs.tf
├── server.tf
└── variables.tf
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can now plan, apply and destroy the infrastructure as usual. Make sure to set the values for the variables (via the prompt, environment variables and/or a .tfvars file).&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ terraform apply
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

server_id = &amp;#34;11223344&amp;#34;
server_ipv4 = &amp;#34;1.2.3.4&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="further-information"&gt;Further information&lt;/h2&gt;
&lt;p&gt;For further information check out the full documentation of the Hetzner Cloud provider on the &lt;a href="https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs"&gt;Terraform Registry site&lt;/a&gt;.&lt;/p&gt;</description></item><item><title>Terraform: Change EC2 user_data without recreating instance</title><link>https://davidhamann.de/2022/06/09/terraform-change-ec2-user-data-without-recreation/</link><pubDate>Thu, 09 Jun 2022 00:00:00 +0000</pubDate><guid>https://davidhamann.de/2022/06/09/terraform-change-ec2-user-data-without-recreation/</guid><description>&lt;p&gt;When you have set up your infrastructure with Terraform and then do any change to the &lt;code&gt;user_data&lt;/code&gt; of a EC2 instance, Terraform will detect the change and generally do a force-replacement of the instance. In the planning stage this could look something like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; # aws_instance.web_backend must be replaced
-/+ resource &amp;#34;aws_instance&amp;#34; &amp;#34;web_backend&amp;#34; {
 [...]
 ~ user_data = &amp;#34;1c4e236bd5dec74fecc99d3a3d57679b9b12a927&amp;#34; -&amp;gt; &amp;#34;f8d9add08d4ead74d44af35452c6070dbfcb1576&amp;#34; # forces replacement
 + user_data_base64 = (known after apply)
 [...]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So what can you do when you want to make changes to &lt;code&gt;user_data&lt;/code&gt; but don&amp;rsquo;t want to destroy your instance and create a new one?&lt;/p&gt;
&lt;p&gt;For this case there is a &lt;a href="https://www.terraform.io/language/meta-arguments/lifecycle"&gt;&lt;code&gt;lifecycle&lt;/code&gt; meta-argument&lt;/a&gt; in which you can customize certain resource behaviours.&lt;/p&gt;
&lt;p&gt;The argument we are looking for is &lt;code&gt;ignore_changes&lt;/code&gt;. You can declare this to tell Terraform to ignore changes to certain attributes of a resource that occur after its creation.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;user_data&lt;/code&gt; of a EC2 instance is just an example here – you may use it for any other data as well. But sticking with this example, let&amp;rsquo;s see how our modified EC2 instance could look like:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;aws_instance&amp;#34; &amp;#34;web_backend&amp;#34; {
 [...]
 user_data = local.ec2_user_data

 # don&amp;#39;t force-recreate instance if only user data changes
 lifecycle {
 ignore_changes = [user_data]
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Making changes to &lt;code&gt;user_data&lt;/code&gt; and then running &lt;code&gt;terraform plan&lt;/code&gt; again, you will see that Terraform won&amp;rsquo;t pick up the change and thus won&amp;rsquo;t tell you that the resource must be replaced.&lt;/p&gt;</description></item><item><title>Getting started with Terraform and Infrastructure as Code</title><link>https://davidhamann.de/2020/05/20/terraform-infrastructure-as-code-intro/</link><pubDate>Wed, 20 May 2020 00:00:00 +0000</pubDate><guid>https://davidhamann.de/2020/05/20/terraform-infrastructure-as-code-intro/</guid><description>&lt;p&gt;I recently worked with &lt;a href="https://www.terraform.io"&gt;Terraform&lt;/a&gt; to codify IT infrastructure, i.e. server deployments, network configurations and other resources. Based on my working notes, I want to give an introduction on how to write infrastructure resource definitions and execute them using Terraform.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll be using AWS as a cloud provider in my examples, but many more providers are available. In fact, one of the advantages of using a platform agnostic tool is that you can manage all your infrastructure in one place – not individually for every provider or on-premise platform you use.&lt;/p&gt;
&lt;p&gt;As this is supposed to be a relatively high-level overview, we&amp;rsquo;re only going to create an EC2 instance and don&amp;rsquo;t involve other services or additional configuration management tools yet.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;If you want to follow along, you&amp;rsquo;ll need&amp;hellip;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an AWS account&lt;/li&gt;
&lt;li&gt;an IAM user you&amp;rsquo;d like to use&lt;/li&gt;
&lt;li&gt;a default VPC for the AWS region you choose to use&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;terraform&lt;/code&gt; cli tool installed (using a package manager, a &lt;a href="https://www.terraform.io/downloads.html"&gt;pre-compiled binary&lt;/a&gt;, or by &lt;a href="https://github.com/hashicorp/terraform/blob/master/BUILDING.md"&gt;building it yourself&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The IAM user needs programatic access and should have permissions to create EC2 resources under your account. If you already have access credentials set up on your machine, I suggest adding the access key and secret for the new user as a separate profile in your &lt;code&gt;credentials&lt;/code&gt; file (&lt;code&gt;~/.aws/credentials&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;It could look something like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[my-terraform-profile]
aws_access_key_id = ABC
aws_secret_access_key = DEF
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For later reference, I&amp;rsquo;ve published a &lt;a href="https://gist.github.com/davidhamann/71aec97c54ee0fa5da6ad201e72a4190"&gt;gist of the terraform configuration&lt;/a&gt; we are going to create in the next steps.&lt;/p&gt;
&lt;h2 id="why"&gt;Why&lt;/h2&gt;
&lt;p&gt;Before we start setting up our initial configuration, let&amp;rsquo;s think about why would we would want to manage our infrastructure in code in the first place.&lt;/p&gt;
&lt;p&gt;A couple of reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;not having to manually configure each VM deployment and thus reducing manual configuration errors (this is even more relevant when you&amp;rsquo;re dealing with lots of different VMs in multiple networks, many different security groups, etc. – not cool to do by hand)&lt;/li&gt;
&lt;li&gt;making it easy to repeatedly and programatically set things up with a (hopefully) good baseline configuration&lt;/li&gt;
&lt;li&gt;having a clearly defined and visible configuration and state of your resources&lt;/li&gt;
&lt;li&gt;treating your infrastructure like your other code and benefiting from all the tools you&amp;rsquo;re already using, e.g. version control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In addition, codifying your infrastructure means you can (re)create and tear down resources very easily and basically not just &lt;em&gt;start&lt;/em&gt; an idle resource when you need it, but actually only &lt;em&gt;build&lt;/em&gt; it when you need it – which is exactly what we&amp;rsquo;re going to do in the next steps.&lt;/p&gt;
&lt;p&gt;Having said that, not all is rosy. You still have many chances to mess up configurations and set up a whole park of insecure resources :-)&lt;/p&gt;
&lt;h2 id="main-terraform-commands"&gt;Main Terraform commands&lt;/h2&gt;
&lt;p&gt;For the following examples, keep in mind that we&amp;rsquo;re mostly dealing with these four &lt;code&gt;terraform&lt;/code&gt; commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;terraform init&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;terraform plan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;terraform apply&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;terraform destroy&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Basically, we&amp;rsquo;re going to &lt;code&gt;init&lt;/code&gt; a configuration, &lt;code&gt;plan&lt;/code&gt; the creation, and then &lt;code&gt;apply&lt;/code&gt; it one or many times, and explicitely or implicitely &lt;code&gt;destroy&lt;/code&gt; resources.&lt;/p&gt;
&lt;h2 id="first-configuration-and-initialization"&gt;First configuration and initialization&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s create a folder &lt;code&gt;terraform-ec2&lt;/code&gt; with a file named &lt;code&gt;main.tf&lt;/code&gt;. This is where our configuration, written in HCL (Hashicorp Configuration Language), goes.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;main.tf&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;provider &amp;#34;aws&amp;#34; {
 profile = &amp;#34;terraform&amp;#34;
 region = &amp;#34;eu-central-1&amp;#34;
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We&amp;rsquo;re defining a provider with whom we want to interact to manage the resources. For AWS I&amp;rsquo;m using the profile &amp;ldquo;terraform&amp;rdquo;, which is the profile I defined earlier in &lt;code&gt;~/.aws/credentials&lt;/code&gt;, and the region &amp;ldquo;eu-central-1&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Going to the directory and running &lt;code&gt;terraform init&lt;/code&gt; will now make Terraform parse the file, check the defined provider and download a plugin for interacting with said provider (you&amp;rsquo;ll find it in &lt;code&gt;.terraform/plugins&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The output will be something like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider &amp;#34;aws&amp;#34; (hashicorp/aws) 2.62.0...

[...]

Terraform has been successfully initialized!

[...]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Naturally, you can do this for other providers as well and create resources in multiple locations.&lt;/p&gt;
&lt;h2 id="defining-resources"&gt;Defining resources&lt;/h2&gt;
&lt;p&gt;Now that we know which provider to target, let&amp;rsquo;s define an EC2 instance and a security group:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;aws_instance&amp;#34; &amp;#34;my_vm&amp;#34; {
 ami = &amp;#34;ami-0b6d8a6db0c665fb7&amp;#34;
 instance_type = &amp;#34;t2.micro&amp;#34;
 key_name = &amp;#34;terraform&amp;#34;
 security_groups = [aws_security_group.ssh_http.name]
}

resource &amp;#34;aws_security_group&amp;#34; &amp;#34;ssh_http&amp;#34; {
 name = &amp;#34;ssh_http&amp;#34;
 description = &amp;#34;Allow SSH and HTTP&amp;#34;

 ingress {
 from_port = 22
 to_port = 22
 protocol = &amp;#34;tcp&amp;#34;
 cidr_blocks = [&amp;#34;x.x.x.x/32&amp;#34;] # make this your IP/IP Range
 }
 ingress {
 from_port = 80
 to_port = 80
 protocol = &amp;#34;tcp&amp;#34;
 cidr_blocks = [&amp;#34;0.0.0.0/0&amp;#34;]
 }
 egress {
 from_port = 0
 to_port = 0
 protocol = &amp;#34;-1&amp;#34;
 cidr_blocks = [&amp;#34;0.0.0.0/0&amp;#34;]
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We&amp;rsquo;re defining a new EC2 instance in the first &lt;code&gt;resource&lt;/code&gt; block and are naming it &lt;code&gt;my_vm&lt;/code&gt; in this configuration.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ami&lt;/code&gt; refers to an ID of an Amazon Machine Image (AMI). We could make/pre-configure our own but to keep things simple in this tutorial, we&amp;rsquo;re going to use the ID of a public Ubuntu 18 image in the eu-central-1 region. Note that if you picked a different region in the provider configuration, you need to look for an AMI in your region instead (e.g. in the EC2 console -&amp;gt; Images -&amp;gt; AMI, or via the &lt;a href="https://cloud-images.ubuntu.com/locator/ec2/"&gt;Ubuntu AMI Locator&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;instance_type&lt;/code&gt; we are choosing the vm configuration – in this case &lt;code&gt;t2.micro&lt;/code&gt;, which gives us a 1 vCPU, 1 GB memory machine that is more than enough for playing around.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;key_name&lt;/code&gt; is another essential attribute. Here, we specify the name of a keypair that we previously created and uploaded to our AWS account (e.g. in the EC2 console -&amp;gt; Key pairs -&amp;gt; Import key pair). We could also create it in this configuration (&lt;code&gt;resource &amp;quot;aws_key_pair&amp;quot;&lt;/code&gt;), but to not introduce too many new steps, we&amp;rsquo;ll be using an already existing one. Make sure to also have the private key readily available on your machine for later steps.&lt;/p&gt;
&lt;p&gt;There are many more resources and configuration keys – have a look at the &lt;a href="https://www.terraform.io/docs/providers/aws/"&gt;provider docs&lt;/a&gt; for more details. For now we are only going to define the security group before finally seeing what we are acutally creating with this config.&lt;/p&gt;
&lt;p&gt;The security group is referenced in the &lt;code&gt;aws_instance&lt;/code&gt; section by its name and defined in its own resource block &lt;code&gt;aws_security_group&lt;/code&gt; (note that all aws resources have the &lt;code&gt;aws_&lt;/code&gt; prefix).&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re not familiar with security groups on AWS: it&amp;rsquo;s essentially a virtual firewall with rules for your ingress (inbound) and egress (outbound) network traffic.&lt;/p&gt;
&lt;p&gt;So here we are defining an allow rule for inbound tcp traffic on port 22 and 80. If you don&amp;rsquo;t want to have 22 open to the world, add the IP address you&amp;rsquo;re coming from for the &lt;code&gt;cidr_blocks&lt;/code&gt; key. &amp;ldquo;-1&amp;rdquo; in the egress section means &amp;ldquo;all&amp;rdquo;; the rest should be self-explanatory.&lt;/p&gt;
&lt;h2 id="plan-execution"&gt;Plan execution&lt;/h2&gt;
&lt;p&gt;Now we&amp;rsquo;re finally ready to see what our configuration would do when being executed. Let&amp;rsquo;s run &lt;code&gt;terraform plan&lt;/code&gt;, which will not yet create the resources but tell us if we have configured everything syntactically correct (there&amp;rsquo;s also &lt;code&gt;terraform validate&lt;/code&gt; if you only want that), do a dry-run and then give us information that can be known from what we defined.&lt;/p&gt;
&lt;p&gt;The output (shortened) will look like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[...]
Terraform will perform the following actions:

 # aws_instance.my_vm will be created
 + resource &amp;#34;aws_instance&amp;#34; &amp;#34;my_vm&amp;#34; {
 + ami = &amp;#34;ami-0b6d8a6db0c665fb7&amp;#34;
 + arn = (known after apply)
 + associate_public_ip_address = (known after apply)
 + availability_zone = (known after apply)
 [...]
 }

 # aws_security_group.ssh_http will be created
 + resource &amp;#34;aws_security_group&amp;#34; &amp;#34;ssh_http&amp;#34; {
 + arn = (known after apply)
 + description = &amp;#34;Allow SSH and HTTP&amp;#34;
 [...]
 }

Plan: 2 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;+&lt;/code&gt; here means adding. Later when things are changed, we&amp;rsquo;ll see &lt;code&gt;-&lt;/code&gt; for removing or &lt;code&gt;~&lt;/code&gt; for updating in-place. Many keys show &amp;ldquo;known after apply&amp;rdquo; as these values are only determined at execution time.&lt;/p&gt;
&lt;p&gt;With our dry-run we can see that we would create 2 new resources. Let&amp;rsquo;s go ahead and run &lt;code&gt;terraform apply&lt;/code&gt; to make it reality.&lt;/p&gt;
&lt;h2 id="apply"&gt;Apply&lt;/h2&gt;
&lt;p&gt;Since we didn&amp;rsquo;t store the previous execution plan, a fresh one is created and we&amp;rsquo;re asked for confirmation.&lt;/p&gt;
&lt;p&gt;The output of applying our configuration will look something like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;aws_security_group.ssh_http: Creating...
aws_security_group.ssh_http: Creation complete after 5s [id=sg-xxx]
aws_instance.my_vm: Creating...
aws_instance.my_vm: Still creating... [10s elapsed]
aws_instance.my_vm: Still creating... [20s elapsed]
aws_instance.my_vm: Creation complete after 29s [id=i-xxx]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;First, the security group was created and then the EC2 instance (which was depending on the group). We should now be able to ssh into the box with &lt;code&gt;ssh -i our-private-key ubuntu@ip-of-new-instance&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Generally, dependencies are resolved automatically (as we have seen here), but can also be provided manually if Terraform cannot know them (&lt;code&gt;depends_on&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Besides creating the resources, Terraform also created a local state file (&lt;code&gt;terraform.tfstate&lt;/code&gt;) in our working directory. This file contains information about the state after creation (you&amp;rsquo;ll see that all previously unknown keys now have values) and is later used to check for changes to the real infrastructure and to know what to change/destroy when config adjustments are being applied. Generally, the state will always be refreshed (synced with the current &lt;em&gt;actual&lt;/em&gt; state of your resources) when you plan/apply your next config. As a convenience, you can show the currently stored state with &lt;code&gt;terraform show&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="change"&gt;Change&lt;/h2&gt;
&lt;p&gt;For demonstration purposes, let&amp;rsquo;s change the &lt;code&gt;instance_type&lt;/code&gt; to &lt;code&gt;t2.nano&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;resource &amp;#34;aws_instance&amp;#34; &amp;#34;my_vm&amp;#34; {
 ami = &amp;#34;ami-0b6d8a6db0c665fb7&amp;#34;
 instance_type = &amp;#34;t2.nano&amp;#34;
 [...]
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And now run &lt;code&gt;terraform apply&lt;/code&gt; again:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;# aws_instance.my_vm will be updated in-place
~ resource &amp;#34;aws_instance&amp;#34; &amp;#34;my_vm&amp;#34; {
 [...]
 instance_state = &amp;#34;running&amp;#34;
 ~ instance_type = &amp;#34;t2.micro&amp;#34; -&amp;gt; &amp;#34;t2.nano&amp;#34;
[...]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We successfully changed our instance type and can also see that the &lt;code&gt;terraform.tfstate&lt;/code&gt; file has changed (compare to &lt;code&gt;terraform.tfstate.backup&lt;/code&gt;, which was automatically created).&lt;/p&gt;
&lt;p&gt;Generally, Terraform will always tell you if an in-place change is possible or if it will destroy/re-create your resources (important in case you would need to extract data before destruction).&lt;/p&gt;
&lt;h2 id="destroy"&gt;Destroy&lt;/h2&gt;
&lt;p&gt;Knowing what we created, we can now just as easily destroy all our resources by executing &lt;code&gt;terraform destroy&lt;/code&gt; (again, after a confirmation of the execution plan).&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;aws_security_group.ssh_http: Refreshing state... [id=sg-xxx]
aws_instance.my_vm: Refreshing state... [id=i-xxx]
[...]
Destroy complete! Resources: 2 destroyed.
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="further-provisioning"&gt;Further provisioning&lt;/h2&gt;
&lt;p&gt;While we could work with our own pre-configured images (instead of using a public AMI like we did above), it is also possible to define some initialization steps after creation of a resource – both locally (on the machine running the terraform command) and remote (on the created system).&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s keep things simple and just define a couple of remote commands that will be executed via SSH after creation of our VM. Note that this will only be possible if the machine is already configured to receive connections. If the remote service (such as SSH or WinRM) needs to be configured/started first, you might want to first have a look at cloud initialization configs, such as writing your script in a &lt;code&gt;user_data&lt;/code&gt; section for EC2 instances. For the Ubuntu image we chose, everything is already set up for us.&lt;/p&gt;
&lt;p&gt;Since we earlier configured our security group to allow inbound traffic at port 80, let&amp;rsquo;s install an Apache webserver on our instance by just using commands executed with the &lt;a href="https://www.terraform.io/docs/provisioners/remote-exec.html"&gt;remote-exec&lt;/a&gt; provisioner for demonstration purposes.&lt;/p&gt;
&lt;p&gt;Add the following block into the &lt;code&gt;aws_instance&lt;/code&gt; resource block:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;provisioner &amp;#34;remote-exec&amp;#34; {
 inline = [
 &amp;#34;sleep 10&amp;#34;,
 &amp;#34;sudo apt-get update&amp;#34;,
 &amp;#34;sudo apt-get -y install apache2&amp;#34;
 ]
 connection {
 type = &amp;#34;ssh&amp;#34;
 user = &amp;#34;ubuntu&amp;#34;
 private_key = file(&amp;#34;~/.ssh/terraform&amp;#34;)
 host = self.public_ip
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;inline&lt;/code&gt; steps will be invoked after resource creation and be executed over SSH using the specified key and the IP of the newly created instance. I&amp;rsquo;ve added a short delay as a quick hack to let the cloud init finish and prevent dpkg locks.&lt;/p&gt;
&lt;p&gt;For further convenvience we&amp;rsquo;ll also add an output block at the very end of our &lt;code&gt;main.tf&lt;/code&gt; that will show us the public IP (hint: you can also access these output values later on with &lt;code&gt;terraform output&lt;/code&gt; as they are stored as part of the state file).&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;output &amp;#34;instance_ip&amp;#34; {
 value = aws_instance.my_vm.public_ip
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Let&amp;rsquo;s &lt;code&gt;terraform apply&lt;/code&gt; and watch the install fly by. At the end, we get the public IP:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Outputs:

instance_ip = x.x.x.x
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If everything worked well, navigating to that IP with a browser will show us the Apache default page.&lt;/p&gt;
&lt;h2 id="making-it-a-bit-nicer"&gt;Making it a bit nicer&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s destroy our resources again (&lt;code&gt;terraform destroy&lt;/code&gt;) and then do one last thing before wrapping up: making the config a bit nicer by using variables, instead of sprinkling hardcoded values everywhere.&lt;/p&gt;
&lt;p&gt;To create default values for our variables, we&amp;rsquo;re creating a new file &lt;code&gt;terraform.tfvars&lt;/code&gt; (if named like this, it&amp;rsquo;ll be read by default):&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;ssh_source_ips = [&amp;#34;x.x.x.x/28&amp;#34;, &amp;#34;x.x.x.x/32&amp;#34;]
ami_owner_id = &amp;#34;099720109477&amp;#34;
key = [&amp;#34;terraform&amp;#34;, &amp;#34;~/.ssh/terraform&amp;#34;]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here, we define two source CIDR blocks with a couple of addresses that we allow for SSH, an owner ID of the AMI we want and our keypair values from before (name and local path).&lt;/p&gt;
&lt;p&gt;The AMI owner ID is that of Canonical (also see &lt;a href="https://cloud-images.ubuntu.com/locator/ec2/"&gt;Ubuntu&amp;rsquo;s AMI locator&lt;/a&gt;) so that we can dynamically select the latest ubuntu 18 image (see the following changes) and make our script work for regions other than eu-central-1 (as we don&amp;rsquo;t have the hardcoded ami ID anymore).&lt;/p&gt;
&lt;p&gt;This is how our final &lt;code&gt;main.tf&lt;/code&gt; will look:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;variable &amp;#34;region&amp;#34; { type = string }
variable &amp;#34;ssh_source_ips&amp;#34; { type = list(string) } # list of CIDR blocks
variable &amp;#34;ami_owner_id&amp;#34; { type = string }
variable &amp;#34;key&amp;#34; { type = tuple([string, string]) } # key_name and path to local private key

provider &amp;#34;aws&amp;#34; {
 profile = &amp;#34;terraform&amp;#34;
 region = var.region
}

data &amp;#34;aws_ami&amp;#34; &amp;#34;ubuntu_18&amp;#34; {
 most_recent = true
 owners = [var.ami_owner_id]

 filter {
 name = &amp;#34;name&amp;#34;
 values = [&amp;#34;ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*&amp;#34;]
 }
}

resource &amp;#34;aws_instance&amp;#34; &amp;#34;my_vm&amp;#34; {
 ami = data.aws_ami.ubuntu_18.id
 instance_type = &amp;#34;t2.micro&amp;#34;
 key_name = var.key[0]
 security_groups = [aws_security_group.ssh_http.name]

 provisioner &amp;#34;remote-exec&amp;#34; {
 inline = [
 &amp;#34;sleep 10&amp;#34;,
 &amp;#34;sudo apt-get update&amp;#34;,
 &amp;#34;sudo apt-get -y install apache2&amp;#34;,
 ]
 connection {
 type = &amp;#34;ssh&amp;#34;
 user = &amp;#34;ubuntu&amp;#34;
 private_key = file(var.key[1])
 host = self.public_ip
 }
 }

}

resource &amp;#34;aws_security_group&amp;#34; &amp;#34;ssh_http&amp;#34; {
 name = &amp;#34;ssh_http&amp;#34;
 description = &amp;#34;Allow SSH and HTTP&amp;#34;

 ingress {
 from_port = 22
 to_port = 22
 protocol = &amp;#34;tcp&amp;#34;
 cidr_blocks = var.ssh_source_ips
 }
 ingress {
 from_port = 80
 to_port = 80
 protocol = &amp;#34;tcp&amp;#34;
 cidr_blocks = [&amp;#34;0.0.0.0/0&amp;#34;]
 }
 egress {
 from_port = 0
 to_port = 0
 protocol = &amp;#34;-1&amp;#34;
 cidr_blocks = [&amp;#34;0.0.0.0/0&amp;#34;]
 }
}

output &amp;#34;instance_ip&amp;#34; {
 value = aws_instance.my_vm.public_ip
}

output &amp;#34;chosen_ami&amp;#34; {
 value = data.aws_ami.ubuntu_18.id
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;All variables are defined at the top. Besides &lt;code&gt;source_ips&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt; and &lt;code&gt;ami_owner_id&lt;/code&gt;, we have &lt;code&gt;region&lt;/code&gt; which we haven&amp;rsquo;t given a default value in the &lt;code&gt;terraform.tfvars&lt;/code&gt; file. Once we execute our config, we&amp;rsquo;ll be asked for a value (alternatively, we could also pass it with a &lt;code&gt;-var&lt;/code&gt; flag with the &lt;code&gt;apply&lt;/code&gt; command).&lt;/p&gt;
&lt;p&gt;The image selection is now done in a data source block; we&amp;rsquo;re basically filtering for the most recent bionic image from owner Canonical and then using its ID in the instance block.&lt;/p&gt;
&lt;p&gt;At the very end we additionally output the chosen AMI.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s now run the new config by executing &lt;code&gt;terraform plan&lt;/code&gt;. Enter a desired region and check the ami-id in the execution plan. It&amp;rsquo;ll differ when you run another &lt;code&gt;plan&lt;/code&gt; and choose a different region.&lt;/p&gt;
&lt;p&gt;Finally, apply the config and check that everything works. At the time of writing, for &lt;code&gt;eu-central-1&lt;/code&gt; the output should look like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Outputs:

chosen_ami = ami-0b6d8a6db0c665fb7
instance_ip = x.x.x.x
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="explore-further"&gt;Explore further&lt;/h2&gt;
&lt;p&gt;Obviously, you can do much, much more and also do things better than outlined here. I hope my notes gave you enough information to start exploring Terraform, or &amp;ldquo;infrastructure as code&amp;rdquo; in general, further. Especially once you start dealing with more resources, you&amp;rsquo;ll appreciate this more transparent and more actionable way of managing resources. Like with any automation, it just feels good once everything is set up and works by just pushing a few keys :-)&lt;/p&gt;</description></item></channel></rss>