Building My Personal Website: From Idea to Automated Deployment (Part 2)
Source: Dev.to
Overview
In the first part of this series I covered the high‑level architecture and the tools I chose for building my personal website. This post dives deeper into the technical implementation, starting with the Terraform modules needed to deploy a minimal Hetzner Cloud infrastructure.
The required components are:
- Network – private network for internal communication
- Firewall – security rules to restrict traffic
- SSH Key – authentication for server access
- Server – the compute instance
Each component is managed by its own Terraform module.
Network
The terraform-hcloud-network module provides comprehensive network management for Hetzner Cloud, including optional creation of a new network, support for multiple subnets, custom routes, and consistent outputs.
module "network" {
source = "danylomikula/network/hcloud"
version = "1.0.0"
create_network = true
name = local.project_slug
ip_range = "10.100.0.0/16"
labels = local.common_labels
subnets = {
web = {
type = "cloud"
network_zone = "eu-central"
ip_range = "10.100.1.0/24"
}
}
}
I chose the eu-central network zone for its pricing. The configuration creates a /16 CIDR block (10.100.0.0/16) with a single /24 subnet (10.100.1.0/24), which is ample for a single server.
Firewall
The terraform-hcloud-firewall module allows creation of multiple firewalls with custom inbound and outbound rules. In this setup I only allow HTTP/HTTPS traffic from Cloudflare IP addresses and SSH access from my home IP.
module "firewall" {
source = "danylomikula/firewall/hcloud"
version = "1.0.0"
firewalls = {
"${local.resource_names.website}" = {
rules = [
{
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.my_homelab_ip]
description = "allow ssh"
},
{
direction = "in"
protocol = "tcp"
port = "80"
source_ips = local.cloudflare_all_ips
description = "allow http from cloudflare"
},
{
direction = "in"
protocol = "tcp"
port = "443"
source_ips = local.cloudflare_all_ips
description = "allow https from cloudflare"
},
{
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
description = "allow ping"
}
]
labels = {
service = "firewall"
}
}
}
common_labels = local.common_labels
}
Dynamic Cloudflare IPs
Cloudflare publishes its IP ranges publicly. They are fetched dynamically with Terraform’s http data source, ensuring the firewall stays up‑to‑date.
data "http" "cloudflare_ips_v4" {
url = "https://www.cloudflare.com/ips-v4"
}
data "http" "cloudflare_ips_v6" {
url = "https://www.cloudflare.com/ips-v6"
}
locals {
cloudflare_ipv4_cidrs = split("\n", trimspace(data.http.cloudflare_ips_v4.response_body))
cloudflare_ipv6_cidrs = split("\n", trimspace(data.http.cloudflare_ips_v6.response_body))
cloudflare_all_ips = concat(local.cloudflare_ipv4_cidrs, local.cloudflare_ipv6_cidrs)
}
Note: Enable the Proxy toggle on your A and AAAA records in Cloudflare DNS settings for this configuration to work correctly.
SSH Key
The terraform-hcloud-ssh-key module handles SSH key management, supporting automated key generation, local storage, and importing existing keys.
module "ssh_key" {
source = "danylomikula/ssh-key/hcloud"
version = "1.0.0"
create_key = true
name = local.project_slug
save_private_key_locally = true
local_key_directory = path.module
labels = local.common_labels
}
By default an ED25519 key pair is generated and saved locally, providing a secure and recommended authentication method.
Server
The terraform-hcloud-server module creates the compute instance and ties together the previously defined resources.
module "servers" {
source = "danylomikula/server/hcloud"
version = "1.0.0"
servers = {
"${local.resource_names.website}" = {
server_type = "cx23"
location = "hel1"
image = data.hcloud_image.rocky.name
user_data = local.cloud_init_config
ssh_keys = [module.ssh_key.ssh_key_id]
firewall_ids = [module.firewall.firewall_ids[local.resource_names.website]]
networks = [{
network_id = module.network.network_id
ip = "10.100.1.10"
}]
labels = {
service = "website"
}
}
}
common_labels = local.common_labels
}
The cx23 type is the cheapest Hetzner offering (≈ $5 / month in Helsinki) and is more than sufficient for a static website. All identifiers—SSH key ID, firewall ID, and network ID—are passed dynamically from the respective module outputs, eliminating manual configuration and reducing error risk.
Full Terraform Configuration
Below is the complete Terraform configuration that brings together all the pieces described above.
locals {
project_slug = "mikula-dev"
common_labels = {
environment = "production"
project = local.project_slug
managed_by = "terraform"
}
resource_names = {
website = "${local.project_slug}-web"
}
cloud_init_config = templatefile("${path.module}/cloud-init.tpl", {
ansible_ssh_public_key = var.ansible_user_ssh_public_key
})
cloudflare_ipv4_cidrs = split("\n", trimspace(data.http.cloudflare_ips_v4.response_body))
cloudflare_ipv6_cidrs = split("\n", trimspace(data.http.cloudflare_ips_v6.response_body))
cloudflare_all_ips = concat(local.cloudflare_ipv4_cidrs, local.cloudflare_ipv6_cidrs)
}
/* Fetch Cloudflare IP ra