Prasanth Janardhanan

Setting up a Bastion host and a three-node cluster on Hetzner cloud using Terraform and Ansible

Infrastructure as code has a great benefit - you can make a cluster available in a few minutes. Then, switch the configuration with a few updates to the configuration. If you ever had long hours staring at blinking LEDs while it installed from a stack of CDs, you will know what a relief this is.

Terraform makes it possible to declaratively create the cloud infrastructure, supports all major cloud providers, and is easy to learn if you can spare an afternoon.

Hetzner is one of the most affordable and at the same time reliable cloud service provider. You can get a decent VM for around EUR 3 per month. That is just EUR 9 p/m for a small cloud.

Setup terraform on your local computer

If not done already, now is the time to set up terraform on your local laptop. The instructions at terraform website are fairly straightforward.

Create an SSH Key and add to Hetzner

You will need the SSH key to log on to your cloud servers. First, create a new ssh key.

ssh-keygen -f ~/.ssh/tcloud -t ed25519

Then copy the public key.

cat ~/.ssh/tcloud.pub

Go to Hetzner cloud console. In the left side panel, look for an icon labeled Security. Add the SSH key copied in the last step.

Hetzner cloud console

Get a Hetzner API token

In the same Security tab in the Hetzner cloud console, create
an API token. Copy the API token and keep it safe. You will have to set the environment variable HCLOUD_TOKEN to be this API token before running the terraform commands.

The network setup

Network setup with bastion host that has access from outside network and all the nodes inside the network

The bastion host can be directly accessed remotely; The nodes can not be accessed directly from the public network.

The load balancer sends the traffic from the public network to the nodes.

Let us setup a network and a bastion host

The way we setup our cluster is like this: First, we setup a network for our cluster. Then we will have a Bastion host inside the network.

Base network - without the cluster nodes

We will login only to the Bastion host and not to the individual nodes in the cluster. The Bastion host will in turn, connect to the individual nodes.

We will configure the firewall such that only the traffic from the local network is allowed for the cluster. Finally, we will set up a load balancer that will route the external traffic to the cluster nodes.

Let us now move on to writing some Terraform code.

On to the terraform code

You can clone the code from the OneCluster github repository.

See the 1-infra/modules/base folder.

There are 4 files.

The variables.tf file contains values that we can customize.

variable "bastion" {
    type = object({
        name         = string
        server_type  = string
        private_ip   = string
    })
}

For example, you can set the server_type variable to “cx11” that is the smallest VM type with 2GB RAM and 1 vCPU.

The variables can have default values. See that the private_ip_range variable has a default value. Makes it convenient when you use this base module.

variable "private_ip_range" {
  type=string
  default = "10.0.0.0/16"
}

The main.tf file contains the code that sets up our infrastructure.

This block of code sets up the network:

resource "hcloud_network" "private_net" {
  name     = var.network_name
  ip_range = var.private_ip_range
  labels = {
    name = var.network_name
  }
}

resource "hcloud_network_subnet" "private_subnet" {
  network_id   = hcloud_network.private_net.id
  type         = "server"
  network_zone = var.private_network_zone
  ip_range     = var.private_ip_range
}

Notice how the variables are used rather than hard-coding all the parameters.

The next part of main.tf creates a bastion node and connects it to the network:

resource "hcloud_server" "bastion" {
  name        = var.bastion.name
  image       = "ubuntu-20.04" 
  server_type = var.bastion.server_type
  location    = var.hcloud_location
  ssh_keys    = ["accesskey"]
}

resource "hcloud_server_network" "server_network_bastion" {
  network_id = hcloud_network.private_net.id
  server_id  = hcloud_server.bastion.id
  ip         = var.bastion.private_ip
}

outputs.tf contains the output values from this terraform module. For the Base module, we will need the IP address of the bastion host.

output "bastion_ip"{
  value = hcloud_server.bastion.ipv4_address
  description = "Bastion Server IP"
}

The code in the folder 1-infra/modules/base is a reusable Terraform module. We will now write the code that uses the module, customizes the variables and builds the infrastructure.

See the file: mycloud/base/main.tf In the first part, we specify the infrastructure provider “hcloud” Then initialize our “base” module. We have to provide values for all the variables that does not have a default value.

terraform {
  required_version = ">= 0.13"
}

provider "hcloud" {
}

module "base" {
  source = "../../modules/base"
  
  bastion ={
    name = "bastion"
    server_type = "cx31"
    private_ip   = "10.0.0.2"    
  }
  network_name="mycloud-main"
}

output "bastion_ip"{
  value = module.base.bastion_ip
  description = "Bastion Server IP"
}

Create the Base infrastructure.

Now that the code is ready, let us setup our base infrastructure. Make sure that the environment variable “HCLOUD_TOKEN” is setup to be the API token from Hetzner.

Then run the command

cd ./1-infra/mycluster/base
terraform init

Using a bash script

Alternatively, you can make use of the bash script run.sh . Create a file named .env Then save HCLOUD_TOKEN in the .env file like this:

HCLOUD_TOKEN=asjdfhkjsdhfjdshkjhfkdsjhfkjdshfkjdshkjhfkj

Run the script like this:

./run.sh init base

You can see the corresponding code in the bash script

function initBase(){
   cd ./1-infra/mycluster/base
   terraform init
}

Once the initialization is done, run the “apply” command

cd ./1-infra/mycluster/base
terraform apply

If everything goes fine, this should build the base network and print out the IP address of the bastion host.

(Alternatively, using the bash script: ./run.sh deploy base )

Try logging on to the bastion host through SSH. Use the IP address from the previous step. For example:

ssh -i ~/.ssh/tcloud root@115.103.34.228

Set up a 3 node cluster

Now that we have a base infrastructure setup, let us build a 3 node cluster. See the modules/hcloud folder

Let us start from main.tf:

data "hcloud_network" "private_net" {
    name = var.network_name
}

First we get the network name from the variables and use it to initialize the network for this module.

The next block of code builds the cluster nodes(servers)

resource "hcloud_server" "cloud_nodes" {
  for_each = var.nodes

  name        = each.value.name
  image       = "ubuntu-20.04" 
  server_type = each.value.server_type
  location    = var.hcloud_location
  ssh_keys    = ["accesskey"]
}

foreach runs a loop. Let us quickly switch to the variables.tf file to better understand how this foreach works.

variable "nodes" {
    type = map(object({
        name         = string
        server_type  = string
        private_ip   = string
    }))
}

The variable in this case is a “map”. The name of the variable is “nodes”. Each of the element contains an object with values: name, server_type, and private_ip variables.

Back in the foreach loop, we can access the name like this: each.value.name and the server_type like this: each.value.server_type

Let us see how the values are passed in mycloud/prod/main.tf

module "cluster" {
    source = "../../modules/hcloud"

    nodes = {
        1 = {
            name = "node1"
            server_type = "cx21"
            private_ip   = "10.0.0.5"
        }
        
        2 = {
            name = "node2"
            server_type = "cx21"
            private_ip   = "10.0.0.6"
        }
        
        3 = {
            name = "node3"
            server_type = "cx21"
            private_ip   = "10.0.0.7"
        }
    }
}

Now that we have the nodes setup, we can add additional components to our cluster. Let us first add a small volume to each node. Volumes is like a virtual Hard Disk. Having a separate HDD for application data is convenient. Here is the code:

resource "hcloud_volume" "volumes" {
  for_each = var.nodes
  
  name = "${each.value.name}-volx"
  size = 10
  server_id = hcloud_server.cloud_nodes[each.key].id
  automount = false
}

This block of code also runs a loop because we want to add a volume to each of the nodes in the cluster. Notice that I hard-coded the size of the volume. (size = 10). Not a good practice. I should have used a variable. The other interesting line is where it makes a name for the volume here name = "${each.value.name}-volx" That line just combines the name of the node and makes it easy check which volume corresponds to which node.

Connect the node to the network

The next block connects each of the node to the network and sets the private IP address of the node.

resource "hcloud_server_network" "server_network" {
  for_each = var.nodes

  network_id = data.hcloud_network.private_net.id
  server_id  = hcloud_server.cloud_nodes[each.key].id
  ip         = each.value.private_ip
}

Load balancer

We will need a load balancer to distribute the external traffic to the nodes in the cluster.

Here is the code to setup one:

resource "hcloud_load_balancer" "load_balancer" {
  name       = "${var.cluster_name}-lb"
  load_balancer_type = var.load_balancer.type
  location   = var.hcloud_location
  dynamic "target"{
    for_each = var.nodes
    content{
      type = "server"
      server_id= hcloud_server.cloud_nodes[target.key].id
    }
  }
}

We will also add the load balancer to our private network. Remember we allow only local traffic for the nodes in our cluster. So the load balancer will only communicate through the private IP address of the nodes in the cluster.

resource "hcloud_load_balancer_network" "server_network_lb" {
  load_balancer_id = hcloud_load_balancer.load_balancer.id
  network_id = data.hcloud_network.private_net.id
  ip = var.load_balancer.private_ip
}

Setup a cluster

We have the module ready that can create a cluster in our private network. See the code in the folder: mycloud/prod

In the first part we initialize the map of nodes for the cluster:

module "cluster" {
    source = "../../modules/hcloud"
    
    cluster_name = "prod"
    network_name = "simfatic-main"

    nodes = {
        1 = {
            name = "node1"
            server_type = "cx21"
            private_ip   = "10.0.0.5"
        }
        
        2 = {
            name = "node2"
            server_type = "cx21"
            private_ip   = "10.0.0.6"
        }
        
        3 = {
            name = "node3"
            server_type = "cx21"
            private_ip   = "10.0.0.7"
        }
    }

    load_balancer = {
        type="lb11"
        private_ip="10.0.0.3"
    }

    hcloud_location = "nbg1"
}

You may have noticed that making a separate module makes it easy to build clusters of different size and shapes. You can add more nodes in the main.tf to build a larger cluster. You can change the server-type variable to vertically scale.

There can be many cluster setups each one for a different purpose. For example the cluster staging can be used to run tests on apps just about to be released. A dev-cluster can be used for development time tests. Any of these clusters can be stood up on demand.

Output

output "nodes" {
  value = module.cluster.nodes
  description = "Node Details"
}
output "load_balancer"{
  value = module.cluster.load_balancer
  description = "LoadBalancer IP"
}

The output configuration provides the external IP addresses of the nodes and the load balancer. We can uses these IP addresses in the next step - that is to provision the nodes using Ansible.

Build a cluster

go to the folder run terraform init

cd mycluster/prod
terraform init

Then run terraform apply

terraform apply

Destroy the cluster

Once you have the terraform code working, you are ready with the “infrastructure as code”. You can build the cluster on demand any time you want. That also means that you can destroy the ones that you don’t need. In order to destroy the test cluster run the command

cd mycloud/prod
terraform destroy

to destroy the base setup:

cd mycloud/base
terraform destroy

Next steps

It is only the bare nodes that we have setup so far. Before you can run any useful application on this cluster, you need to provision these nodes. For example, setup user and admin accounts, Firewall, SSH so that the admin user can login, so on and so forth. Fortunately, Ansible makes the setup pretty easy just like terraform made it easy for the infrastructure. It is the topic of discussion for another session.