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.
Required components
- 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
- Consistent outputs
Example Usage
module "network" {
source = "danylomikula/network/hcloud"
version = "1.0.0"
# Create a new network
create_network = true
name = local.project_slug
ip_range = "10.100.0.0/16"
# Optional labels
labels = local.common_labels
# Define subnets
subnets = {
web = {
type = "cloud"
network_zone = "eu-central"
ip_range = "10.100.1.0/24"
}
}
}
Notes
- Network zone:
eu-centralwas chosen for its pricing advantages. - CIDR blocks:
- Network CIDR:
10.100.0.0/16 - Subnet CIDR:
10.100.1.0/24(sufficient for a single server)
- Network CIDR:
Feel free to add additional subnets or routes as needed.
Firewall
The terraform-hcloud-firewall module lets you create multiple firewalls with custom inbound and outbound rules. In this example we:
- Allow HTTP/HTTPS traffic only from Cloudflare IP addresses.
- Allow SSH access only from a personal home IP.
- Permit ICMP (ping) from anywhere.
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. By fetching them with Terraform’s http data source, the firewall rules stay up‑to‑date automatically.
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 manages SSH keys for Hetzner Cloud. It can:
- Generate a new key pair automatically.
- Store the private key locally (optional).
- Import an existing key if you prefer to manage it yourself.
By default the module creates an ED25519 key pair, which is the recommended, secure algorithm.
Example Usage
module "ssh_key" {
source = "danylomikula/ssh-key/hcloud"
version = "1.0.0"
# Create a new key pair
create_key = true
name = local.project_slug
# Save the private key locally (optional)
save_private_key_locally = true
local_key_directory = path.module
# Apply common labels to the key resource
labels = local.common_labels
}
create_key– Set totrueto generate a new key pair.name– The name to assign to the key in Hetzner Cloud.save_private_key_locally– Whentrue, the private key is written tolocal_key_directory.local_key_directory– Directory where the private key file will be saved (defaults to the module’s directory).labels– Optional map of labels to attach to the key resource.
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
}
- Instance type:
cx23– the cheapest Hetzner offering (≈ $5 / month in Helsinki) and more than sufficient for a static website. - Dynamic identifiers: SSH key ID, firewall ID, and network ID are sourced from the respective module outputs, eliminating manual configuration and reducing the risk of errors.
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 ranges for firewall rules */
data "http" "cloudflare_ips_v4" {
url = "https://www.cloudflare.com/ips-v4"
}
data "http" "cloudflare_ips_v6" {
url = "https://www.cloudflare.com/ips-v6"
}
/* Network */
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"
}
}
}
/* SSH Key */
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
}
/* Firewall */
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
}
/* Server */
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
}