Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ http:
tls:
certResolver: letsencrypt
middlewares:
- hsts-headers
- redirect-to-app
service: noop@internal
redirect-apex:
Expand All @@ -18,6 +19,7 @@ http:
tls:
certResolver: letsencrypt
middlewares:
- hsts-headers
- redirect-to-app
service: noop@internal

Expand All @@ -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"
13 changes: 6 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
This diagram shows how the various components work together:

<details>
<summary>Click to expand</summary>
<summary>Click to expand diagram</summary>

```mermaid
graph TB
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions docs/dns.md
Original file line number Diff line number Diff line change
@@ -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).

<details>
<summary>Click to expand diagram</summary>

```mermaid
flowchart LR
subgraph transip["TransIP (registrar + DNS)"]
ZONE["Zone: example.com"]
PROD_REC["prod A/AAAA<br/>@ A/AAAA<br/>registry A/AAAA<br/>www CNAME<br/>email anti-spoofing<br/>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
```

</details>

## 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.<domain>` |
| **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/.
4 changes: 3 additions & 1 deletion docs/technologies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
12 changes: 9 additions & 3 deletions tasks/Taskfile.terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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}}"
Expand Down
22 changes: 22 additions & 0 deletions terraform/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 133 additions & 0 deletions terraform/dns.tf
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions terraform/providers.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ provider "hcloud" {
token = local.hcloud_token
}

provider "transip" {
account_name = var.transip_account_name
private_key = var.transip_private_key
}

11 changes: 11 additions & 0 deletions terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions terraform/versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ terraform {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
transip = {
source = "aequitas/transip"
version = "~> 0.1"
}
}
}