Building My Personal Website: From Idea to Automated Deployment (Part 2)

Published: (December 4, 2025 at 10:50 AM EST)
5 min read
Source: Dev.to

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-central was 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)

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 to true to generate a new key pair.
  • name – The name to assign to the key in Hetzner Cloud.
  • save_private_key_locally – When true, the private key is written to local_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
}
Back to Blog

Related posts

Read more »