feat: add Hetzner-hosted todo application stack

Implement the server-rendered Go todo application with PostgreSQL persistence, Tailwind styling, health checks, and test coverage.

Provision production and staging infrastructure with OpenTofu, including private networking, static IPs, Object Storage backups, and load balancing.

Configure Rocky Linux hosts with Ansible, rootless Podman, PostgreSQL, WAL-G backup automation, staging restores, and generated inventory.
This commit is contained in:
cole-maxwell1
2026-08-06 21:34:56 -05:00
commit 1382c42803
68 changed files with 2380 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/aws" {
version = "5.69.0"
constraints = "~> 5.0, ~> 5.69.0"
hashes = [
"h1:24yeNWqOfM+nPkJHlg86rR1axakHRJyMOjVzVZ5UARk=",
"h1:4rhzgp2XTwv8kDEUIWl7yUCk5fYu9eVH7VNheRTgp5Y=",
"h1:8tg9jRiB0LiFrnkSEjvPh1YKTu41dl9wwR5+7bC54zg=",
"h1:JSsC5+jtHtpuJKgbE8Nu+LB0GTcUj44P9UEdCTD6WBU=",
"h1:U2T/EeTZPLdDn34j4XD5VUnTrhNbTRqM6/XZypO8aDY=",
"h1:VCpHaMUt5w0IUw2VVhdIX6rU8qcHCECufnoAbavonyw=",
"h1:Y8SU7HBR9jjaw+xQOso505ym+VHfW+gm/MFNfTcZtpg=",
"h1:bmyh6fXoWQJVLEPPb/jmd5qCWlSnVEdAoxQADqrKqUc=",
"h1:cNxrGkDy64UQ9GiAT+MvlDq1oUxnVMSXP2xLKL8+7sY=",
"h1:oG99aP+wUZ2A1V3fGu/1wwhTdkDq8ByqbF5jtSCDeKo=",
"zh:120dffdf0c32d412a9b1f06810a863950c4e95b5438e35fd102cb0f893bfb0ba",
"zh:22c42d0e42b0edaf45ccc080825cba0ee332d0b29ca5858ba35d4a042d1a5c83",
"zh:2b53841bed8787d914d687b0d295d7c5bce2adea6e306f2cacbef1f418abcdee",
"zh:338e4491a2136c48de3de5e7c1aad1e59c3cb029caebbc4d5bf9a27e5cc3882d",
"zh:4cec5ab455647659ac766ae5dcd576f2c527a977b285fac864cf2eaffa035874",
"zh:7146550606696505b32bd9db0ce1096f6738773063ba0c3e61063333e71b1204",
"zh:afbd454623d10c1b1e2f47c77689c72a8d845e321874549766fbb4eb082cfc77",
"zh:c28cf51df765fa939d343fae99210896b590b5f58c63c824f3a3e5f4f2fa1405",
"zh:c2dada7c25ab277573d57736041066df6f5eadbc6459a4302ff9f72cfdd4c287",
"zh:eca3546b97e3df0dc860b99534a32bc3b6f44c123545b03b6733adbf4f150466",
]
}
provider "registry.opentofu.org/hetznercloud/hcloud" {
version = "1.68.0"
constraints = "~> 1.52"
hashes = [
"h1:226Z1Y5A30w6lKA7/EG6vzPn8inyZ9XYbnmpIzZcA58=",
"h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
"h1:6PW+TynH6Po3bSEZ+3+qSeKN/KvuH3N3fWrm9t8HNcc=",
"h1:8zYaD/LRDEAJDwyZx/gbbBL01b9oeGr1mhQeBCNmuhM=",
"h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
"h1:CPBOIltDqRCFo99PHVgt/8z5r+P3JyyiJygsbIozZC4=",
"h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
"h1:KvAWG1HTh3EO8PNymwQQ9+zpAQa3wRvzLvRjRM5jxq4=",
"h1:Zxdt/lmDDS+Uty9kViYoYNxYRlC3CUk2v4l3+XPcibo=",
"h1:gWVxkXIPIJcwAl74VMTz0hgaiJ/2feZIo4KMRQ+l9io=",
"h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
"h1:vXPWFLMNZzSEutr1Z66zicXVx4QAzUXqm3LCYXQH81U=",
"h1:ynTUfNuk5ppK9aniyVQi9HcQ+pdYmoGFX9U2LJgfzd0=",
"zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
"zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
"zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
"zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
"zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
"zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
"zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
"zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
"zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
"zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
"zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
"zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
"zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
]
}
@@ -0,0 +1,14 @@
hcloud_token = "replace-with-hcloud-token"
project_name = "todo"
network_zone = "eu-central"
server_location = "nbg1"
server_type = "cx22"
ssh_key_ids = [123456]
ssh_source_cidrs = ["203.0.113.10/32"]
prod_private_ip = "10.0.1.10"
s3_access_key = "replace-with-object-storage-access-key"
s3_secret_key = "replace-with-object-storage-secret-key"
s3_endpoint = "https://nbg1.your-objectstorage.com"
s3_region = "nbg1"
bucket_name = "replace-with-globally-unique-backup-bucket"
@@ -0,0 +1 @@
staging_private_ip = "10.0.1.20"
+140
View File
@@ -0,0 +1,140 @@
provider "hcloud" {
token = var.hcloud_token
}
provider "aws" {
access_key = var.s3_access_key
secret_key = var.s3_secret_key
region = var.s3_region
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
s3_use_path_style = true
endpoints {
s3 = var.s3_endpoint
}
}
resource "hcloud_network" "main" {
name = "${var.project_name}-network"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "main" {
network_id = hcloud_network.main.id
type = "cloud"
network_zone = var.network_zone
ip_range = "10.0.1.0/24"
}
resource "hcloud_firewall" "nodes" {
name = "${var.project_name}-nodes"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = var.ssh_source_cidrs
}
rule {
direction = "in"
protocol = "tcp"
port = "8080"
source_ips = [hcloud_network.main.ip_range]
}
rule {
direction = "in"
protocol = "icmp"
source_ips = [
"0.0.0.0/0",
"::/0",
]
}
}
module "prod" {
source = "./modules/compute"
name = "${var.project_name}-prod"
server_type = var.server_type
location = var.server_location
ssh_key_ids = var.ssh_key_ids
firewall_ids = [hcloud_firewall.nodes.id]
network_id = hcloud_network.main.id
private_ip = var.prod_private_ip
environment = "prod"
depends_on = [hcloud_network_subnet.main]
}
module "staging" {
source = "./modules/compute"
name = "${var.project_name}-staging"
server_type = var.server_type
location = var.server_location
ssh_key_ids = var.ssh_key_ids
firewall_ids = [hcloud_firewall.nodes.id]
network_id = hcloud_network.main.id
private_ip = var.staging_private_ip
environment = "staging"
depends_on = [hcloud_network_subnet.main]
}
module "storage" {
source = "./modules/storage"
bucket_name = var.bucket_name
}
resource "hcloud_load_balancer" "app" {
name = "${var.project_name}-lb"
load_balancer_type = "lb11"
location = var.server_location
algorithm {
type = "round_robin"
}
}
resource "hcloud_load_balancer_network" "app" {
load_balancer_id = hcloud_load_balancer.app.id
network_id = hcloud_network.main.id
depends_on = [hcloud_network_subnet.main]
}
resource "hcloud_load_balancer_target" "prod" {
type = "server"
load_balancer_id = hcloud_load_balancer.app.id
server_id = module.prod.server_id
use_private_ip = true
depends_on = [
hcloud_load_balancer_network.app,
module.prod,
]
}
resource "hcloud_load_balancer_service" "http" {
load_balancer_id = hcloud_load_balancer.app.id
protocol = "http"
listen_port = 80
destination_port = 8080
http {
sticky_sessions = false
}
health_check {
protocol = "http"
port = 8080
interval = 15
timeout = 10
retries = 3
http {
path = "/healthz"
status_codes = ["200"]
}
}
}
+39
View File
@@ -0,0 +1,39 @@
resource "hcloud_primary_ip" "ipv4" {
name = "${var.name}-ipv4"
type = "ipv4"
auto_delete = false
location = var.location
}
resource "hcloud_primary_ip" "ipv6" {
name = "${var.name}-ipv6"
type = "ipv6"
auto_delete = false
location = var.location
}
resource "hcloud_server" "this" {
name = var.name
image = "rocky-10"
server_type = var.server_type
location = var.location
ssh_keys = var.ssh_key_ids
firewall_ids = var.firewall_ids
labels = {
environment = var.environment
application = "todo"
}
public_net {
ipv4 = hcloud_primary_ip.ipv4.id
ipv4_enabled = true
ipv6 = hcloud_primary_ip.ipv6.id
ipv6_enabled = true
}
}
resource "hcloud_server_network" "this" {
server_id = hcloud_server.this.id
network_id = var.network_id
ip = var.private_ip
}
+5
View File
@@ -0,0 +1,5 @@
output "server_id" { value = hcloud_server.this.id }
output "name" { value = hcloud_server.this.name }
output "private_ip" { value = hcloud_server_network.this.ip }
output "ipv4" { value = hcloud_primary_ip.ipv4.ip_address }
output "ipv6" { value = hcloud_primary_ip.ipv6.ip_address }
+8
View File
@@ -0,0 +1,8 @@
variable "name" { type = string }
variable "server_type" { type = string }
variable "location" { type = string }
variable "ssh_key_ids" { type = list(number) }
variable "firewall_ids" { type = list(number) }
variable "network_id" { type = number }
variable "private_ip" { type = string }
variable "environment" { type = string }
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.52"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
resource "aws_s3_bucket" "backups" {
bucket = var.bucket_name
force_destroy = false
}
resource "aws_s3_bucket_versioning" "backups" {
bucket = aws_s3_bucket.backups.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
id = "abort-incomplete-uploads"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
+6
View File
@@ -0,0 +1,6 @@
output "bucket" {
value = {
id = aws_s3_bucket.backups.id
name = aws_s3_bucket.backups.bucket
}
}
+1
View File
@@ -0,0 +1 @@
variable "bucket_name" { type = string }
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
+60
View File
@@ -0,0 +1,60 @@
output "network" {
value = {
id = hcloud_network.main.id
subnet_id = hcloud_network_subnet.main.id
subnet = hcloud_network_subnet.main.ip_range
prod_ip = module.prod.private_ip
staging_ip = module.staging.private_ip
}
}
output "instances" {
value = {
prod = module.prod
staging = module.staging
}
}
output "load_balancer" {
value = {
id = hcloud_load_balancer.app.id
ipv4 = hcloud_load_balancer.app.ipv4
ipv6 = hcloud_load_balancer.app.ipv6
hostname = hcloud_load_balancer.app.name
}
}
output "backup_bucket" {
value = module.storage.bucket
}
output "ansible_inventory" {
description = "Secret-free Ansible inventory for the provisioned nodes."
value = yamlencode({
all = {
vars = {
ansible_user = "root"
}
children = {
prod = {
hosts = {
(module.prod.name) = {
ansible_host = module.prod.ipv4
private_ip = module.prod.private_ip
deployment_environment = "prod"
}
}
}
staging = {
hosts = {
(module.staging.name) = {
ansible_host = module.staging.ipv4
private_ip = module.staging.private_ip
deployment_environment = "staging"
}
}
}
}
}
})
}
+67
View File
@@ -0,0 +1,67 @@
variable "hcloud_token" {
type = string
sensitive = true
}
variable "project_name" {
type = string
default = "todo"
}
variable "network_zone" {
type = string
default = "eu-central"
}
variable "server_location" {
type = string
default = "nbg1"
}
variable "server_type" {
type = string
default = "cx22"
}
variable "ssh_key_ids" {
type = list(number)
}
variable "ssh_source_cidrs" {
type = list(string)
description = "Trusted public CIDRs permitted to SSH to nodes."
}
variable "prod_private_ip" {
type = string
default = "10.0.1.10"
}
variable "staging_private_ip" {
type = string
default = "10.0.1.20"
}
variable "s3_access_key" {
type = string
sensitive = true
}
variable "s3_secret_key" {
type = string
sensitive = true
}
variable "s3_endpoint" {
type = string
description = "Hetzner Object Storage endpoint, for example https://nbg1.your-objectstorage.com."
}
variable "s3_region" {
type = string
default = "nbg1"
}
variable "bucket_name" {
type = string
}
+21
View File
@@ -0,0 +1,21 @@
terraform {
required_version = ">= 1.8.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.52"
}
aws = {
source = "hashicorp/aws"
/*
Pinned to version due to versions v5.70.0
and newer wait for an AWS-specific response
header that Hetzner's S3-compatible service
does not return.
https://github.com/hashicorp/terraform-provider-aws/issues/49019
https://github.com/hashicorp/terraform-provider-aws/issues/43333
*/
version = "~> 5.69.0"
}
}
}