diff --git a/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 b/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 index 8b13c39..4e9f12c 100644 --- a/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 +++ b/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 @@ -9,6 +9,7 @@ http: tls: certResolver: letsencrypt middlewares: + - hsts-headers - redirect-to-app service: noop@internal redirect-apex: @@ -18,6 +19,7 @@ http: tls: certResolver: letsencrypt middlewares: + - hsts-headers - redirect-to-app service: noop@internal @@ -27,3 +29,7 @@ http: regex: "^https://(www\\.)?{{ base_domain | regex_escape() | replace('\\', '\\\\') }}(.*)" replacement: "https://{{ traefik_redirect_target }}$2" permanent: true + hsts-headers: + headers: + customResponseHeaders: + Strict-Transport-Security: "max-age=31536000; includeSubDomains" diff --git a/docs/README.md b/docs/README.md index 6473569..a7767ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ This diagram shows how the various components work together:
- Click to expand + Click to expand diagram ```mermaid graph TB @@ -53,19 +53,18 @@ graph TB ## Reference -**Infrastructure** - +**Main components** - [Traefik](traefik.md) — Reverse proxy, TLS, operations, adding apps - [Registry](registry.md) — Auth, commands, operations - [Observe](monitoring.md) — OpenObserve, dashboards, logs - [Workflows](workflows.md) — Scheduled tasks and multi-step workflows with Prefect -**Application** - +**Application management** - [Application deployment](application-deployment.md) — Commands, app mount, records, implementation details -- [Secrets](secrets.md) — File locations, editing, SOPS, app secrets +- [Secrets management](secrets.md) — File locations, editing, SOPS, app secrets -**Operations** +**Operations and infrastructure** +- [DNS](dns.md) — Zone and records managed by Terraform (Hetzner DNS) - [Backups](backups.md) — Automated application aware backups using Restic - [Remote-SSH](remote-ssh.md) — Connect to the server via Remote-SSH (tunnel, dashboards) - [Private (local config)](private.md) — Local config files diff --git a/docs/dns.md b/docs/dns.md new file mode 100644 index 0000000..b7e2a72 --- /dev/null +++ b/docs/dns.md @@ -0,0 +1,58 @@ +[**<---**](README.md) + +# DNS + +DNS zones and records are managed by Terraform via the [aequitas/transip](https://registry.terraform.io/providers/aequitas/transip/latest) provider. TransIP acts as both registrar and authoritative DNS provider. DNSSEC is managed by TransIP defaults/policy. + +**Configuration:** [`terraform/dns.tf`](../terraform/dns.tf) + +## How it works + +TransIP manages the zone automatically as registrar — no zone creation step needed. Each workspace manages the DNS records that point to its own server. Both workspaces are independent (no ordering constraint). + +
+ Click to expand diagram + +```mermaid +flowchart LR + subgraph transip["TransIP (registrar + DNS)"] + ZONE["Zone: example.com"] + PROD_REC["prod A/AAAA
@ A/AAAA
registry A/AAAA
www CNAME
email anti-spoofing
DNSSEC"] + DEV_REC["dev A/AAAA"] + end + + subgraph terraform["Terraform"] + PROD_WS["platform-prod"] + DEV_WS["platform-dev"] + end + + PROD_WS --> PROD_REC + DEV_WS --> DEV_REC +``` + +
+ +## Record ownership + +| Workspace | Records | Dynamic? | +|-----------|---------|----------| +| **prod** | `prod` A/AAAA, `@` A/AAAA, `registry` A/AAAA | Yes — derived from server IP | +| **prod** | `www` CNAME | Static — points to `prod.` | +| **prod** | Null MX, SPF, DMARC | Static — email anti-spoofing | +| **prod** | DNSSEC | Managed by TransIP defaults/policy | +| **dev** | `dev` A/AAAA | Yes — derived from server IP; destroyed with dev server | + +## DNSSEC + +DNSSEC is enabled and managed by TransIP defaults/policy outside Terraform. + +## Adding a new domain + +For a new app on a different domain: + +- If the domain is registered at TransIP: no code changes — set `base_domain` in `iac.yml` and apply. +- If the domain is at another registrar: delegate NS records to TransIP's nameservers (`ns0.transip.net`, `ns1.transip.nl`, `ns2.transip.eu`), then apply. + +## Credentials + +`transip_account_name` and `transip_private_key` are stored in the sops-encrypted `app/.iac/iac.yml` and passed to Terraform as `TF_VAR_` environment variables by the Taskfile. Generate the key pair at https://www.transip.eu/cp/account/api/. diff --git a/docs/technologies.md b/docs/technologies.md index 7e41d95..e615071 100644 --- a/docs/technologies.md +++ b/docs/technologies.md @@ -14,7 +14,9 @@ Most versioned dependencies live in a package file; [Renovate](https://docs.reno | **Server provisioning** | | | | [Ansible](https://docs.ansible.com/) | Configuration management | [`requirements.txt`](../requirements.txt), [`ansible/requirements.yml`](../ansible/requirements.yml) | | [hcloud](https://github.com/hetznercloud/cli) | Hetzner Cloud CLI | [`mise.toml`](../mise.toml) | -| [Terraform](https://developer.hashicorp.com/terraform) | IaC for cloud resources | [`terraform/versions.tf`](../terraform/versions.tf) | +| [Terraform](https://developer.hashicorp.com/terraform) | IaC for cloud resources (servers, DNS) | [`terraform/versions.tf`](../terraform/versions.tf) | +| **DNS** | | | +| [TransIP DNS](https://www.transip.eu/knowledgebase/155-dns-and-nameservers/) | DNS records and DNSSEC managed by Terraform. | [`terraform/dns.tf`](../terraform/dns.tf) | | **Secrets** | | | | [Age](https://github.com/FiloSottile/age) | Encryption (age format) | [`mise.toml`](../mise.toml) | | [SOPS](https://getsops.io/) | Encrypt secrets in Git. See [Secrets](secrets.md) | [`mise.toml`](../mise.toml) | diff --git a/tasks/Taskfile.terraform.yml b/tasks/Taskfile.terraform.yml index 72407a6..7bcd17b 100644 --- a/tasks/Taskfile.terraform.yml +++ b/tasks/Taskfile.terraform.yml @@ -35,7 +35,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key terraform plan apply: @@ -57,7 +59,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key echo "🔑 Removing old host key for workspace from known_hosts..." task hostkeys:prepare -- "{{.WORKSPACE}}" terraform apply @@ -86,7 +90,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key terraform destroy echo "🧹 Cleaning server host key from known_hosts..." task hostkeys:prepare -- "{{.WORKSPACE}}" diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 24f5b08..9793749 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -1,6 +1,28 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/aequitas/transip" { + version = "0.1.27" + constraints = "~> 0.1" + hashes = [ + "h1:K5D+p14w7w0UgglNbUG5sASg9a9a/V2NjiwHRWh8Ktc=", + "zh:19d1cd6ef49ad8b3d28b5ce47ae990150286d7446fc02009248c0439c3da7be7", + "zh:36e1d80d54cdc1cd54ca72032ef5a2377e6453c5cc1ba6394ebf96e22f84cb13", + "zh:4542356b09ceb7f5d8788ae6e3712119eff811549c0891e94a00b2f5ad523811", + "zh:48440fe4ed018b211d07b29396ae94758a71a4d54f2c0be6886a652c9f687cf9", + "zh:4fa38256cbb48beae1b7887889cc7ee486f27578136d0865caa2d54902ec45dd", + "zh:5d13bd2e7c15066b42487daaed56fa14d99e3c7741adde487c1c98f3f5bdb68b", + "zh:715f3e2b545df138d616fb7fc16217f059eb37a113579ee3e9edeecb686fddee", + "zh:784f7f388d501d6351697a97a80dc945fcb9221053b4ba985d535f5f8416532c", + "zh:7a7b697efab46bacf1c61a87da301851882d41fa5a819437028373aa0cdb07c5", + "zh:959e990486eb8faa786e31548091d3552a90a4aed9865a2941ca5f812c62ce0f", + "zh:bb6ebc885f651ab0a0d093adbe7cbeebf47d70846a71b9ea1ff40cad329eb82f", + "zh:c782ac53112b21145f44b08efe31b8a38de83f7a0885919f8084c27be543ac5e", + "zh:e6c23a687cf6ff8fed3cf80a995b6f01597b48e8b36a1351d5924875a4bbedae", + "zh:f8caaa05b012d4fe992e915f97ef2a0440303bf21ae67b5867165bd3cbcde666", + ] +} + provider "registry.terraform.io/hetznercloud/hcloud" { version = "1.60.1" constraints = "~> 1.45" diff --git a/terraform/dns.tf b/terraform/dns.tf new file mode 100644 index 0000000..52f1aec --- /dev/null +++ b/terraform/dns.tf @@ -0,0 +1,133 @@ +# DNS records and DNSSEC for the platform. +# +# TransIP is both registrar and DNS provider — no zone creation needed. +# Record ownership: each workspace manages records pointing to its own server. +# DNSSEC is managed via transip_domain_dnssec (prod only). + +locals { + is_prod = local.environment == "prod" + is_dev = local.environment == "dev" + server_ipv6 = hcloud_server.platform.ipv6_address +} + +# ────────────────────────────────────────────── +# Dev records (destroyed with dev server) +# ────────────────────────────────────────────── + +resource "transip_dns_record" "dev_a" { + count = local.is_dev ? 1 : 0 + domain = var.base_domain + name = "dev" + type = "A" + expire = 300 + content = [hcloud_server.platform.ipv4_address] +} + +resource "transip_dns_record" "dev_aaaa" { + count = local.is_dev ? 1 : 0 + domain = var.base_domain + name = "dev" + type = "AAAA" + expire = 300 + content = [local.server_ipv6] +} + +# ────────────────────────────────────────────── +# Prod server records +# ────────────────────────────────────────────── + +resource "transip_dns_record" "prod_a" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "prod" + type = "A" + expire = 60 + content = [hcloud_server.platform.ipv4_address] +} + +resource "transip_dns_record" "prod_aaaa" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "prod" + type = "AAAA" + expire = 60 + content = [local.server_ipv6] +} + +resource "transip_dns_record" "apex_a" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "@" + type = "A" + expire = 60 + content = [hcloud_server.platform.ipv4_address] +} + +resource "transip_dns_record" "apex_aaaa" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "@" + type = "AAAA" + expire = 60 + content = [local.server_ipv6] +} + +resource "transip_dns_record" "registry_a" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "registry" + type = "A" + expire = 60 + content = [hcloud_server.platform.ipv4_address] +} + +resource "transip_dns_record" "registry_aaaa" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "registry" + type = "AAAA" + expire = 60 + content = [local.server_ipv6] +} + +resource "transip_dns_record" "www" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "www" + type = "CNAME" + expire = 60 + content = ["prod.${var.base_domain}."] +} + +# ────────────────────────────────────────────── +# Email anti-spoofing (domain does not handle email) +# ────────────────────────────────────────────── + +resource "transip_dns_record" "null_mx" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "@" + type = "MX" + expire = 86400 + content = ["0 ."] +} + +resource "transip_dns_record" "apex_spf" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "@" + type = "TXT" + expire = 86400 + content = ["v=spf1 -all"] +} + +resource "transip_dns_record" "dmarc" { + count = local.is_prod ? 1 : 0 + domain = var.base_domain + name = "_dmarc" + type = "TXT" + expire = 86400 + content = ["v=DMARC1; p=reject;"] +} + +# DNSSEC is managed by TransIP defaults/policy outside Terraform. diff --git a/terraform/providers.tf b/terraform/providers.tf index 4bb051b..69eb8e6 100644 --- a/terraform/providers.tf +++ b/terraform/providers.tf @@ -4,3 +4,8 @@ provider "hcloud" { token = local.hcloud_token } +provider "transip" { + account_name = var.transip_account_name + private_key = var.transip_private_key +} + diff --git a/terraform/variables.tf b/terraform/variables.tf index b06f536..1398353 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -4,6 +4,17 @@ variable "hcloud_token" { sensitive = true } +variable "transip_account_name" { + description = "TransIP API account name" + type = string +} + +variable "transip_private_key" { + description = "TransIP API private key (PEM)" + type = string + sensitive = true +} + variable "ssh_keys" { description = "List of Hetzner Cloud SSH key IDs to authorize on the server" type = list(string) diff --git a/terraform/versions.tf b/terraform/versions.tf index 49c78cc..6876279 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -15,6 +15,10 @@ terraform { source = "hetznercloud/hcloud" version = "~> 1.45" } + transip = { + source = "aequitas/transip" + version = "~> 0.1" + } } }