Creating A Fixed Number Of Servers

Technical Background

Infrastructure automation benefits from being able to provision multiple servers with consistent configuration in a single step. Terraform enables this using the count meta-argument, which allows resources (servers, keys, DNS records, scripts) to be created dynamically based on a configurable number.

For each server, we need to ensure that:
- Each instance has its own unique SSH host key pair to maintain trust separation.
- Each instance receives its own DNS A record for easy access using a predictable naming scheme (your-server-1, your-server-2, …).
- Each instance has its own local helper scripts (ssh, scp) and a known_hosts file placed in a dedicated directory, enabling per-server secure access.

A critical point when generating DNS entries is avoiding configuration mistakes such as duplicate alias names or aliases identical to the server's primary hostname. These cause Terraform plan/apply errors and must be prevented by validation rules in the variable definitions.

Solution

Prerequisits

Create the same files and folder structure as done before in exercise 20 Creating Host With Corresponding DNS Entries.

The final folder structure will be:

src
├── KnownHostsByModule
│   ├── (bin)
│   │   ├── (scp)
│   │   └── (ssh)
│   ├── (gen)
│   │   └── (known_hosts)
│   ├── (work-1)
│   │   ├── (bin)
│   ├── (work-2)
│   │   ├── (bin)
│   ├── tpl
│   │   └── userData.yml
│   ├── config.auto.tfvars
│   ├── secrets.auto.tfvats
│   ├── main.tf
│   ├── network.tf
│   ├── outputs.tf
│   ├── providers.tf
│   └── variables.tf
└── Modules
    └── SshKnownHosts        
        ├── tpl
        │   ├── scp.sh
        │   └── ssh.sh
        ├── main.tf
        └── variables.tf

Note

Ensure that template files and wrapper scripts are placed in the correct paths. Terraform template rendering fails if relative paths are incorrect.

Defining Server Count

  1. Create a variable where the number of servers and the base name gets defined in /KnownHostsByModule/config.auto.tfvars:
server_count   = 2
server_base_name = "work"
  1. Define the needed variables for a multi server setup to /KnownHostsByModule/variables.tf:
variable "server_count" {
  type = number
  validation {
    condition     = var.server_count > 0 && var.server_count <= 10
    error_message = "Server count must be between 1 and 10."
  }
}

variable "server_base_name" {
  type = string
  description = "Base name for the servers (will be appended with -1, -2, etc.)"
}

Info

Validation rules prevent accidental creation of too many servers, which could lead to unexpected cost or quota issues.

Updating Resources in main.tf

  1. Create a locals array in /KnownHostsByModule/main.tf:
locals {
  servers = [for i in range(var.server_count) : "${var.server_base_name}-${i + 1}"]
}

Note

This array holds all dynamically generated server names.

  1. Add a counter to host resource in /KnownHostsByModule/main.tf:
resource "tls_private_key" "host" {
  count = var.server_count
  algorithm = "ED25519"
}

Note

This creates a SSH key pair for each server.

Warning

Never reuse the same private key for multiple servers. Each server must have a unique host key to maintain secure SSH trust.

  1. Add a counter to private and public key resources in /KnownHostsByModule/main.tf:
resource "local_file" "server_private_key" {
  count           = var.server_count
  content         = tls_private_key.host[count.index].private_key_pem
  filename        = "gen/host_private_key_${count.index}.pem"
  file_permission = "600"
}

resource "local_file" "server_public_key" {
  count           = var.server_count
  content         = tls_private_key.host[count.index].public_key_openssh
  filename        = "gen/host_public_key_${count.index}.pub"
  file_permission = "644"
}

Note

600 permissions restrict private key access to the owner, increasing security.

  1. Add count to Cloud-init resource in /KnownHostsByModule/main.tf:
resource "local_file" "user_data" {
  count = var.server_count
  content = templatefile("tpl/userData.yml", {
    host_ed25519_private = local_file.server_private_key[count.index].content
    host_ed25519_public  = local_file.server_public_key[count.index].content
    devopsSSHPublicKey   = hcloud_ssh_key.loginUser.public_key
    volume_name          = hcloud_volume.volume[count.index].name
    volume_id            = hcloud_volume.volume[count.index].id
  })
  filename = "gen/userData_${count.index}.yml"
}

Note

This Cloud-init script injects server-specific keys and volume references into each server at creation time.

  1. Add count to hcloud_server resource in /KnownHostsByModule/main.tf:
resource "hcloud_server" "web" {
  count        = var.server_count
  name         = local.servers[count.index]
  image        = "debian-12"
  server_type  = "cx22"
  firewall_ids = [hcloud_firewall.sshFw.id]
  ssh_keys     = [hcloud_ssh_key.loginUser.id]
  user_data    = local_file.user_data[count.index].content
}

Note

This dynamically provisions one server per count index with its own initialization data.

  1. Update the module connection in /KnownHostsByModule/main.tf:
module "createSshKnownHosts" {
  source              = "../Module/SshKnownHosts"
  loginName           = hcloud_ssh_key.loginUser.name
  dnsZone             = var.dns_zone
  serverName          = var.server_name 
  server_count         = var.server_count 
  server_base_name      = var.server_base_name 
  host_public_keys   = [for key in tls_private_key.host : key.public_key_openssh]
  servers             = local.servers
}

Note

Passing data down into the child module keeps the configuration modular and reusable.

Updating External Files In KnownByHostsModule

  1. Update the output for each server instance in /KnownHostsByModule/outputs.tf:

Display ip and datacenter on start

output "web_ip_addr" {
  value       = [for server in hcloud_server.web : server.ipv4_address]
  description = "The servers' IPv4 addresses"
}

output "web_datacenters" {
  value       = [for server in hcloud_server.web : server.datacenter]
  description = "The servers' datacenters"
}

output "volume_device_names" {
  value       = [for volume in hcloud_volume.volume : volume.linux_device]
  description = "The volumes' device names"
}

output "volume_device_ids" {
  value       = [for volume in hcloud_volume.volume : volume.id]
  description = "The volumes' IDs"
}

output "server_fqdn" {
  description = "Fully qualified domain name of the canonical server"
  value       = "${var.server_name}.${var.dns_zone}"
}

output "alias_fqdns" {
  description = "All alias FQDNs"
  value       = [
    for alias in var.server_aliases : "${alias}.${var.dns_zone}"
  ]
}

Note

Outputs allow quick verification of IP addresses and resource locations after deployment.

  1. Add a count to the resources in /KnownHostsByModule/volumes.tf:
resource "hcloud_volume" "volume" {
  count    = var.server_count
  name     = "volume-${count.index + 1}"
  size      = 10
  location = "hel1"
  format    = "xfs"
  automount = false
}

resource "hcloud_volume_attachment" "volume_attachment" {
  count     = var.server_count
  server_id = hcloud_server.web[count.index].id
  volume_id = hcloud_volume.volume[count.index].id
}

Info

Attaching volumes dynamically ensures every server can store its data separately.

  1. Add count to DNS resources in /KnownHostsByModule/dns-records.tf:
resource "dns_a_record_set" "aliases" {
  count     = length(var.server_aliases)
  zone      = var.dns_zone
  name      = var.server_aliases[count.index]
  addresses = [var.server_ip]
  ttl       = 10
}

resource "dns_a_record_set" "records" {
  count     = var.server_count
  zone      = var.dns_zone
  name      = local.servers[count.index]
  addresses = [hcloud_server.web[count.index].ipv4_address]
  ttl       = 10
}

Warning

DNS propagation delays may cause temporary resolution issues right after terraform apply.

Adjusting the Child Module

  1. Add the missing variable from the parent module to /Module/SshKnownHosts/variables.tf:

variable "loginName" {
  type        = string
  description = "User Name"
}

variable "dnsZone" {
  type        = string
  description = "DNS Zone of the server"
}

variable "serverName" {
  type        = string
  description = "name of the server"
}

variable "server_base_name" {
  type        = string
  description = "name of the server"
}

variable "server_count" {
  type        = string
  description = "Number of servers"
}

variable "servers" {
  type        = list(string)
  description = "User Name"
}

variable "host_public_keys" {
  type        = list(string)
  description = "User Name"
}
  1. Add count to local_file wrapper resources in /Module/SshKnownHosts/main.tf:
resource "local_file" "known_hosts" {
  count    = var.server_count
  content  = "${var.servers[count.index]}.${var.dnsZone} ${var.host_public_keys[count.index]}"
  filename = "gen/known_hosts_${count.index}"
  file_permission = "644"
}

resource "local_file" "ssh_script" {
  count = var.server_count
  content = templatefile("${path.module}/tpl/ssh.sh", {
    dns = "${var.servers[count.index]}.${var.dnsZone}"
    loginName = "${var.loginName}"
  })
  filename = "${var.servers[count.index]}/bin/ssh"
  file_permission = "700"
  depends_on      = [local_file.known_hosts]
}

resource "local_file" "scp_wrapper" {
  content = templatefile("${path.module}/tpl/scp.sh", {
    loginName = "${var.loginName}"
  })
  filename = "bin/scp"
  file_permission = "0755"
}

Note

Generating per-server known_hosts entries simplifies SSH access and reduces risk of MITM warnings.

Deploy and Verify

Initialize and apply Terraform inside /KnownHostsByModule:

terraform init
terraform apply

Success

After applying, verify: - The expected number of servers were created. - DNS records resolve correctly. - Generated directories contain valid ssh and scp scripts and known_hosts entries.

DNS

Terraform Variables

DNS Record Types

Terraform Count

Terraform Validation Rules

Terraform Distinct Function

Terraform Contains