In this section, we will be covering some broad basics on how to use Terraform, creating resources, and planning builds. This is by no means comprehensive but aims to give you a general idea on Terraform and how to write HCL code.
First Steps
Terraform works by interacting with a provider such as docker or other supported applications (available in the Terraform registry). To use a provider, we can copy the code block that they provide and paste it in our main.tf file - we will go over how to structure our project later on.
main.tf
// Terraform uses 2 spaces to indent instead of 4terraform {required_providers { docker = { source ="kreuzwerker/docker" version ="~> 3.0.2"# The squiggly line just looks for any version up to the one we want } }}// Extra config options if you are hosting Docker remotely.provider "docker" { host ="ssh://user@blah:22" ssh_opts = ["-o","StrictHostKeyChecking=no","-o","UserKnownHostsFile=/dev/null"]}
Now that we have a provider specified, we can start adding containers to the mix. In the Terraform Registry, we can see a couple of resources which we can utilise in our code
Let's start with the container resource. Select the docker_container tab then view the example provided:
main.tf
# Start a containerresource "docker_container" "ubuntu" { name ="foo" image = docker_image.ubuntu.image_id}# Find the latest Ubuntu precise image.resource "docker_image" "ubuntu" { name ="ubuntu:precise"}
First, Terraform is going to look for the latest image of Ubuntu precise, which we will then reference the ID of when starting the container
To reference an item, we need to specify the resource type (docker_image) and name (ubuntu) then the item that we want to fetch (image_id).
Using this example, we can go to Docker Hub and pick out an application to deploy - below is an example using Nginx
main.tf
terraform {required_providers { docker = { source ="kreuzwerker/docker" version ="~> 3.0.2" } }}resource "docker_container" "container_nginx" { name ="foo" image = docker_image.nginx.image_id}resource "docker_image" "image_nginx" { name ="nginx:latest"}
To make the code cleaner, we can give the resource name a prefix to ensure that it is self documenting (container_ or image_ etc.)
Now that we have our basic Terraform script ready, we can initialise and apply our code by running the following in our terminal:
terraform initterraform plan -out tf_plan.out # (You can skip this if you run terraform apply)terraform apply -auto-approve # Auto approve will build without asking for confirmation
If successful, we should be able to see the following output and verify by running docker ps
We will notice however that if we try to visit the nginx proxy at http://<docker_instance_ip>, we will receive an error. This is because we are not exposing any container ports which are accessible. To do this, we can revisit the Docker Registry to see the required schema:
Let us update our main.tf file to reflect the changes needed
We can now run terraform apply -auto-approve to update our container. Note that you do not need to run destroy as Terraform will review the plan and change anything that has been updated.
Now try to visit the web page at the external port you've specified
Success! If you have followed all the steps so far, you should have a working Terraform automation script. Let's tear down our infrastructure by running terraform destroy -auto-approve and look into how we can structure our project.
Structuring Terraform Projects
It is good practice to separate our main.tf file into multiple specialised files so it is easier to read and document. It will also come in handy when we want to modularise our project into a dev and prod version for example. The general files that are needed are:
main.tf
provider.tf
network.tf
resource.tf
You do not need to follow the same naming scheme, though it does help to have some form of consistency. Let us create these files, then separate our functions into them.
Update main and resource with the new variables and ensure that your codebase is clean by running terraform fmt
main.tf
resource "docker_container" "container_nginx" { name = var.container_name # Variable Here image = docker_image.nginx.image_idports { internal =80 external =80 }}
resource.tf
resource "docker_image" "nginx" { name = var.docker_image}
> terraform fmt # Format your terraform filesprovider.tfvars.tf> terraform apply -auto-approve # Make sure your code still works
Great! However, the Nginx container variable might not make much sense - instead of specifying the container and its version, we can instead specify the version and change it inline
vars.tf
variable "container_name" { description ="Docker Container Name" type =string default ="nginx_container"}variable "nginx_image_version" { # Updated the name to reflect our change description ="Docker Container Image" type =string default ="1.27"}
resource.tf
resource "docker_image" "nginx" { name ="nginx:${var.nginx_image_version}"}# Use ${stuff} to add code inside of a string
Running terraform plan should show you the version number alongside the container you picked
# docker_image.nginx will be created+resource "docker_image" "nginx" {+ id = (known after apply)+ image_id = (known after apply)+ name ="nginx:1.27"# Right here :)+ repo_digest = (known after apply) }Plan:2 to add, 0 to change, 0 to destroy.
Shortening our Variable
We can use maps instead of having separate variable names to modify the same container to save on space and keep our code even cleaner - let us examine the required variables on our container
main.tf
resource "docker_container" "container_nginx" { name = var.container_name # Requires a name which we get from our variable image = docker_image.nginx.image_idports { internal =80# Accepts two port numbers for internal and external external =80 }}
vars.tf
# Modify the container_name and nginx_image_version variable into a single map objectvariable "nginx_container" { description ="Nginx Container Variables" type =map(string) default = {"name"="nginx_container""version"="1.27""internal_port"=80"external_port"=80 }}
One issue with this is that we specified the map object to be of type string, but we included numbers inside of it. In this case, we can nest an object inside of our map, then specify the data type for each key. Note that you may not run into an issue for this when deploying but it's good for documentation.
vars.tf
variable "nginx_container" { description ="Nginx Container Variables" type =map(object({ name =string, version =string, # We set this as a string because it can either be 'latest' or a number internal_port =number, external_port =number })) default = {"name"="nginx_container""version"="1.27""internal_port"=80"external_port"=80 }}
Now we can go to our other files and modify their attributes by using var.nginx_container.x
resource "docker_image" "nginx" { name ="nginx:${var.nginx_container.version}"}
You can make this as complex or as simple as you'd like. If you want more info on how to use variables, check out this blog.
Deploying Multiple Containers
It's cool and good that we have a functional codebase, but it's practically useless right now. So, let us examine how we can use Terraform to deploy a web application using Nginx as the reverse proxy, and Apache Tomcat as the web server.
We can copy the nginx_container variable and re-use it for our Tomcat container
Run terraform apply and check that your containers have deployed before moving the the next step.
Outputs
Finally, we have outputs. You can specify important data to display to the end user such as IP addresses, hosts, or passwords which they might need to use in order to access the resources that have been deployed by creating an outputs.tf file then referencing the Read-Only data generated by your resources.
outputs.tf
output "nginx_url" { description ="Nginx URL to access the default page" value ="http://${docker_container.container_nginx.hostname}:${var.nginx_container.external_port}"}output "tomcat_url" { description ="Nginx URL to access the default page" value ="http://${docker_container.container_tomcat.hostname}:${var.tomcat_container.external_port}"}
If we run terraform apply -auto-approve now, we should get the output in our terminal window. If you clear this by accident, you can run terraform output to view the outputs. We will see better examples of this later on when we work with cloud resources.
This is not a lesson per-se, but some quality of life scripts to make deployment easier and faster.
PowerShell/BASH
build.ps1
terraform init # Initialises the directory if it has not been alreadyterraform fmtterraform validateterraform plan -out tfplan.outterraform apply -auto-approve