mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 19:02:49 -05:00
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:
Generated
+62
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,8 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
hcloud = {
|
||||
source = "hetznercloud/hcloud"
|
||||
version = "~> 1.52"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
output "bucket" {
|
||||
value = {
|
||||
id = aws_s3_bucket.backups.id
|
||||
name = aws_s3_bucket.backups.bucket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
variable "bucket_name" { type = string }
|
||||
@@ -0,0 +1,8 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user