Implements the staging-environments OpenSpec change. Persistent staging at staging.trails.cool / planner.staging.trails.cool deploys from main; PR opens get a journal-only preview at pr-<N>.staging.trails.cool that shares the persistent planner. cd-staging.yml builds tagged images, manages per-PR Postgres databases and Caddyfile snippets, evicts the oldest preview at the cap of 3, and tears everything down on PR close. staging-cleanup.yml runs weekly to sweep orphaned previews. DNS records (staging + *.staging A/AAAA) already applied to production via tofu. Caddy approach: per-PR Caddyfile snippets imported from /etc/caddy/sites/ and reloaded on each PR event — no wildcard / on-demand TLS, no router service. Production compose gains a trails-shared network for the staging project to reach Postgres, and host.docker.internal on Caddy so it can reverse-proxy staging containers published on the host loopback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
461 lines
10 KiB
HCL
461 lines
10 KiB
HCL
terraform {
|
|
required_version = ">= 1.0"
|
|
|
|
required_providers {
|
|
hcloud = {
|
|
source = "hetznercloud/hcloud"
|
|
version = "~> 1.60"
|
|
}
|
|
}
|
|
}
|
|
|
|
provider "hcloud" {
|
|
token = var.hcloud_token
|
|
}
|
|
|
|
resource "hcloud_ssh_key" "deploy" {
|
|
name = "trails-cool-deploy"
|
|
public_key = var.ssh_public_key
|
|
}
|
|
|
|
resource "hcloud_server" "trails" {
|
|
name = "trails-cool"
|
|
server_type = "cx23"
|
|
image = "ubuntu-24.04"
|
|
location = "fsn1"
|
|
ssh_keys = [hcloud_ssh_key.deploy.id]
|
|
|
|
labels = {
|
|
project = "trails-cool"
|
|
}
|
|
|
|
user_data = <<-EOF
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Install Docker
|
|
curl -fsSL https://get.docker.com | sh
|
|
|
|
# Install Docker Compose plugin
|
|
apt-get install -y docker-compose-plugin
|
|
|
|
# Create app directory
|
|
mkdir -p /opt/trails-cool
|
|
|
|
# Harden SSH
|
|
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'SSHD'
|
|
PasswordAuthentication no
|
|
X11Forwarding no
|
|
AllowTcpForwarding no
|
|
AllowAgentForwarding no
|
|
MaxAuthTries 3
|
|
ClientAliveInterval 300
|
|
ClientAliveCountMax 2
|
|
SSHD
|
|
systemctl reload ssh
|
|
EOF
|
|
|
|
# Hetzner Cloud provider's user_data hash algorithm changed in 1.45+,
|
|
# causing spurious replacement diffs on every plan even when the script
|
|
# is unchanged. user_data only runs on first boot anyway — changes after
|
|
# provisioning have no effect — so we pin the attribute to ignore_changes.
|
|
lifecycle {
|
|
ignore_changes = [user_data]
|
|
}
|
|
}
|
|
|
|
resource "hcloud_firewall" "trails" {
|
|
name = "trails-cool"
|
|
|
|
rule {
|
|
direction = "in"
|
|
protocol = "tcp"
|
|
port = "22"
|
|
source_ips = ["0.0.0.0/0", "::/0"]
|
|
}
|
|
|
|
rule {
|
|
direction = "in"
|
|
protocol = "tcp"
|
|
port = "80"
|
|
source_ips = ["0.0.0.0/0", "::/0"]
|
|
}
|
|
|
|
rule {
|
|
direction = "in"
|
|
protocol = "tcp"
|
|
port = "443"
|
|
source_ips = ["0.0.0.0/0", "::/0"]
|
|
}
|
|
}
|
|
|
|
resource "hcloud_firewall_attachment" "trails" {
|
|
firewall_id = hcloud_firewall.trails.id
|
|
server_ids = [hcloud_server.trails.id]
|
|
}
|
|
|
|
# Private network bridging the flagship (Hetzner Cloud) to the dedicated
|
|
# BRouter host (Hetzner Robot) via vSwitch. Both sides must be in the same
|
|
# location (fsn1 / eu-central).
|
|
#
|
|
# Hetzner requires Cloud servers to attach to a regular "cloud" subnet;
|
|
# the "vswitch" subnet is VLAN-only and cannot host Cloud NICs. The two
|
|
# subnets live side by side in the same network and Hetzner routes between
|
|
# them automatically.
|
|
#
|
|
# 10.0.0.0/16 — overall private network
|
|
# 10.0.0.1 — network gateway (reserved by Hetzner; not usable)
|
|
# 10.0.0.0/24 — cloud subnet (hosts Cloud server NICs)
|
|
# 10.0.0.2 — flagship (assigned below)
|
|
# 10.0.1.0/24 — vSwitch subnet (bridged to Robot VLAN)
|
|
# 10.0.1.10 — dedicated BRouter host (configured on the Robot side
|
|
# as a VLAN sub-interface, not managed by Terraform)
|
|
|
|
resource "hcloud_network" "private" {
|
|
name = "trails-cool"
|
|
ip_range = "10.0.0.0/16"
|
|
|
|
labels = {
|
|
project = "trails-cool"
|
|
}
|
|
}
|
|
|
|
resource "hcloud_network_subnet" "cloud" {
|
|
network_id = hcloud_network.private.id
|
|
type = "cloud"
|
|
network_zone = "eu-central"
|
|
ip_range = "10.0.0.0/24"
|
|
}
|
|
|
|
resource "hcloud_network_subnet" "vswitch" {
|
|
network_id = hcloud_network.private.id
|
|
type = "vswitch"
|
|
network_zone = "eu-central"
|
|
ip_range = "10.0.1.0/24"
|
|
vswitch_id = var.vswitch_id
|
|
}
|
|
|
|
resource "hcloud_server_network" "trails" {
|
|
server_id = hcloud_server.trails.id
|
|
network_id = hcloud_network.private.id
|
|
ip = "10.0.0.2"
|
|
|
|
depends_on = [hcloud_network_subnet.cloud]
|
|
}
|
|
|
|
# Reverse DNS
|
|
|
|
resource "hcloud_rdns" "trails_ipv4" {
|
|
server_id = hcloud_server.trails.id
|
|
ip_address = hcloud_server.trails.ipv4_address
|
|
dns_ptr = "trails.cool"
|
|
}
|
|
|
|
resource "hcloud_rdns" "trails_ipv6" {
|
|
server_id = hcloud_server.trails.id
|
|
ip_address = hcloud_server.trails.ipv6_address
|
|
dns_ptr = "trails.cool"
|
|
}
|
|
|
|
# DNS
|
|
|
|
resource "hcloud_zone_rrset" "root_a" {
|
|
zone = "trails.cool"
|
|
name = "@"
|
|
type = "A"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv4_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "root_aaaa" {
|
|
zone = "trails.cool"
|
|
name = "@"
|
|
type = "AAAA"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv6_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "planner_a" {
|
|
zone = "trails.cool"
|
|
name = "planner"
|
|
type = "A"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv4_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "planner_aaaa" {
|
|
zone = "trails.cool"
|
|
name = "planner"
|
|
type = "AAAA"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv6_address }]
|
|
}
|
|
|
|
# Staging — persistent journal at staging.trails.cool, persistent planner
|
|
# at planner.staging.trails.cool, and PR previews at pr-<N>.staging.trails.cool
|
|
# (covered by the wildcard). All resolve to the flagship; Caddy routes per-host.
|
|
|
|
resource "hcloud_zone_rrset" "staging_a" {
|
|
zone = "trails.cool"
|
|
name = "staging"
|
|
type = "A"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv4_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "staging_aaaa" {
|
|
zone = "trails.cool"
|
|
name = "staging"
|
|
type = "AAAA"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv6_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "staging_wildcard_a" {
|
|
zone = "trails.cool"
|
|
name = "*.staging"
|
|
type = "A"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv4_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "staging_wildcard_aaaa" {
|
|
zone = "trails.cool"
|
|
name = "*.staging"
|
|
type = "AAAA"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv6_address }]
|
|
}
|
|
|
|
# Internal wildcard — all *.internal.trails.cool resolves to the server.
|
|
# Caddy handles routing per-subdomain. No individual DNS entries needed
|
|
# for internal services (Grafana, Prometheus, etc.)
|
|
resource "hcloud_zone_rrset" "internal_wildcard_a" {
|
|
zone = "trails.cool"
|
|
name = "*.internal"
|
|
type = "A"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv4_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "internal_wildcard_aaaa" {
|
|
zone = "trails.cool"
|
|
name = "*.internal"
|
|
type = "AAAA"
|
|
ttl = 300
|
|
records = [{ value = hcloud_server.trails.ipv6_address }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "www_cname" {
|
|
zone = "trails.cool"
|
|
name = "www"
|
|
type = "CNAME"
|
|
ttl = 300
|
|
records = [{ value = "trails.cool." }]
|
|
}
|
|
|
|
# Fastmail Email
|
|
|
|
resource "hcloud_zone_rrset" "mx_root" {
|
|
zone = "trails.cool"
|
|
name = "@"
|
|
type = "MX"
|
|
ttl = 3600
|
|
records = [
|
|
{ value = "10 in1-smtp.messagingengine.com." },
|
|
{ value = "20 in2-smtp.messagingengine.com." },
|
|
]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "mx_wildcard" {
|
|
zone = "trails.cool"
|
|
name = "*"
|
|
type = "MX"
|
|
ttl = 3600
|
|
records = [
|
|
{ value = "10 in1-smtp.messagingengine.com." },
|
|
{ value = "20 in2-smtp.messagingengine.com." },
|
|
]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "mx_mail" {
|
|
zone = "trails.cool"
|
|
name = "mail"
|
|
type = "MX"
|
|
ttl = 3600
|
|
records = [
|
|
{ value = "10 in1-smtp.messagingengine.com." },
|
|
{ value = "20 in2-smtp.messagingengine.com." },
|
|
]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "mail_a" {
|
|
zone = "trails.cool"
|
|
name = "mail"
|
|
type = "A"
|
|
ttl = 3600
|
|
records = [{ value = "103.168.172.65" }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "spf" {
|
|
zone = "trails.cool"
|
|
name = "@"
|
|
type = "TXT"
|
|
ttl = 3600
|
|
records = [{ value = "\"v=spf1 include:spf.messagingengine.com ?all\"" }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "dmarc" {
|
|
zone = "trails.cool"
|
|
name = "_dmarc"
|
|
type = "TXT"
|
|
ttl = 3600
|
|
records = [{ value = "\"v=DMARC1; p=none;\"" }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "dkim_fm1" {
|
|
zone = "trails.cool"
|
|
name = "fm1._domainkey"
|
|
type = "CNAME"
|
|
ttl = 3600
|
|
records = [{ value = "fm1.trails.cool.dkim.fmhosted.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "dkim_fm2" {
|
|
zone = "trails.cool"
|
|
name = "fm2._domainkey"
|
|
type = "CNAME"
|
|
ttl = 3600
|
|
records = [{ value = "fm2.trails.cool.dkim.fmhosted.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "dkim_fm3" {
|
|
zone = "trails.cool"
|
|
name = "fm3._domainkey"
|
|
type = "CNAME"
|
|
ttl = 3600
|
|
records = [{ value = "fm3.trails.cool.dkim.fmhosted.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "dkim_mesmtp" {
|
|
zone = "trails.cool"
|
|
name = "mesmtp._domainkey"
|
|
type = "CNAME"
|
|
ttl = 3600
|
|
records = [{ value = "mesmtp.trails.cool.dkim.fmhosted.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_autodiscover" {
|
|
zone = "trails.cool"
|
|
name = "_autodiscover._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 443 autodiscover.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_caldav" {
|
|
zone = "trails.cool"
|
|
name = "_caldav._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 0 0 ." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_caldavs" {
|
|
zone = "trails.cool"
|
|
name = "_caldavs._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 443 d5979255.caldav.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_carddav" {
|
|
zone = "trails.cool"
|
|
name = "_carddav._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 0 0 ." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_carddavs" {
|
|
zone = "trails.cool"
|
|
name = "_carddavs._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 443 d5979255.carddav.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_imap" {
|
|
zone = "trails.cool"
|
|
name = "_imap._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 0 0 ." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_imaps" {
|
|
zone = "trails.cool"
|
|
name = "_imaps._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 993 imap.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_jmap" {
|
|
zone = "trails.cool"
|
|
name = "_jmap._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 443 api.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_pop3" {
|
|
zone = "trails.cool"
|
|
name = "_pop3._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 0 0 ." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_pop3s" {
|
|
zone = "trails.cool"
|
|
name = "_pop3s._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "10 1 995 pop.fastmail.com." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_submission" {
|
|
zone = "trails.cool"
|
|
name = "_submission._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 0 0 ." }]
|
|
}
|
|
|
|
resource "hcloud_zone_rrset" "srv_submissions" {
|
|
zone = "trails.cool"
|
|
name = "_submissions._tcp"
|
|
type = "SRV"
|
|
ttl = 3600
|
|
records = [{ value = "0 1 465 smtp.fastmail.com." }]
|
|
}
|
|
|
|
# Outputs
|
|
|
|
output "server_ip" {
|
|
value = hcloud_server.trails.ipv4_address
|
|
}
|
|
|
|
output "domain" {
|
|
value = "trails.cool"
|
|
}
|
|
|
|
output "planner_domain" {
|
|
value = "planner.trails.cool"
|
|
}
|
|
|
|
output "flagship_private_ip" {
|
|
value = hcloud_server_network.trails.ip
|
|
description = "Private IP of the flagship on the vSwitch network (reachable from the dedicated BRouter host)"
|
|
}
|