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
- 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"
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- 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"
}
- 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.