Skip to content

Commit 3cc4273

Browse files
BlakeHastingsBlake Hastingsclaude
authored
Add dns-record module + new-vm-with-dns example + ADR (#2)
Adds a thin Terraform module that wraps kenske/technitium's technitium_dns_zone_record so any consumer's TF root can register a <name>.lan A record alongside its proxmox-vm call. The zone itself is intentionally not created here — it remains a singleton owned by homelab-services/services/dns/terraform-config/. The reusable provision-vm.yml workflow gains an opt-in input `create_dns_record: bool` (default false). When true it also loads /technitium/ from Infisical and forwards TECHNITIUM_HOST / TECHNITIUM_TOKEN to the terraform apply as TF_VAR_technitium_*. Existing callers (provision-dns, provision-observability) are unaffected — when the input is false the new step is skipped and the empty TF_VAR_* fall-through is ignored by roots that don't declare those vars. Files: - terraform/modules/dns-record/ — module + README - docs/examples/new-vm-with-dns/ — copy-pasteable root + workflow - docs/decisions/dns-management.md — rationale (why composable, why zone lives in services, provider survey, known gaps) - .github/workflows/provision-vm.yml — opt-in /technitium load - README.md — module-index rows for both new paths Co-authored-by: Blake Hastings <blake@example.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d1ce64 commit 3cc4273

15 files changed

Lines changed: 457 additions & 0 deletions

File tree

.github/workflows/provision-vm.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# /terraform/ TF_VAR_PROXMOX_NODE, TF_VAR_TEMPLATE_ID,
1919
# TF_VAR_GATEWAY, TF_VAR_SSH_PUBLIC_KEY
2020
# /ansible/ ANSIBLE_PRIVATE_KEY
21+
# /technitium/ TECHNITIUM_HOST, TECHNITIUM_TOKEN
22+
# (only required when create_dns_record: true)
2123

2224
name: Provision VM (reusable)
2325

@@ -70,6 +72,11 @@ on:
7072
required: false
7173
type: string
7274
default: "http://192.168.0.161"
75+
create_dns_record:
76+
description: "Also load Technitium creds from Infisical (/technitium/) and expose them to Terraform. Set true if the caller's Terraform root uses the dns-record module from this repo."
77+
required: false
78+
type: boolean
79+
default: false
7380

7481
outputs:
7582
vm_ip:
@@ -130,6 +137,22 @@ jobs:
130137
domain: ${{ inputs.infisical_domain }}
131138
export-type: env
132139

140+
# Opt-in: only loaded when the caller's TF root uses the dns-record module.
141+
# When create_dns_record=false (default) this step is skipped and the
142+
# TF_VAR_technitium_* env vars below stay empty — Terraform ignores
143+
# unused TF_VAR_* inputs, so non-DNS callers see no behaviour change.
144+
- name: Load Technitium secrets from Infisical
145+
if: ${{ inputs.create_dns_record }}
146+
uses: Infisical/secrets-action@v1.0.16
147+
with:
148+
method: oidc
149+
identity-id: ${{ inputs.infisical_identity_id }}
150+
project-slug: ${{ inputs.infisical_project_slug }}
151+
env-slug: prod
152+
secret-path: /technitium
153+
domain: ${{ inputs.infisical_domain }}
154+
export-type: env
155+
133156
- name: Setup Terraform
134157
uses: hashicorp/setup-terraform@v3
135158
with:
@@ -151,6 +174,9 @@ jobs:
151174
TF_VAR_cpu_cores: ${{ inputs.cpu_cores }}
152175
TF_VAR_memory_mb: ${{ inputs.memory_mb }}
153176
TF_VAR_disk_gb: ${{ inputs.disk_gb }}
177+
# Empty when create_dns_record=false; ignored by TF roots that don't declare these vars.
178+
TF_VAR_technitium_host: ${{ env.TECHNITIUM_HOST }}
179+
TF_VAR_technitium_token: ${{ env.TECHNITIUM_TOKEN }}
154180
run: terraform plan -input=false -no-color
155181

156182
- name: Terraform Apply
@@ -163,6 +189,9 @@ jobs:
163189
TF_VAR_cpu_cores: ${{ inputs.cpu_cores }}
164190
TF_VAR_memory_mb: ${{ inputs.memory_mb }}
165191
TF_VAR_disk_gb: ${{ inputs.disk_gb }}
192+
# Empty when create_dns_record=false; ignored by TF roots that don't declare these vars.
193+
TF_VAR_technitium_host: ${{ env.TECHNITIUM_HOST }}
194+
TF_VAR_technitium_token: ${{ env.TECHNITIUM_TOKEN }}
166195
run: terraform apply -input=false -auto-approve -no-color
167196

168197
- name: Get VM IP

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ For deployed services (observability, DNS, etc.) see [homelab-services](https://
1212
|------|-----------|
1313
| `.github/workflows/provision-vm.yml` | Reusable workflow: Terraform + Ansible for any VM |
1414
| `terraform/modules/proxmox-vm/` | Generic Proxmox VM module (cloud-init, DHCP via MAC) |
15+
| `terraform/modules/dns-record/` | Single Technitium A record — compose with `proxmox-vm` to register VMs in DNS |
1516
| `ansible/base-vm.yml` | Configures any service VM: Docker, runner, Node Exporter, Alloy |
1617
| `ansible/infra-runner.yml` | Configures the terraform-runner VM itself |
1718
| `ansible/vars/main.yml` | Platform defaults |
@@ -20,6 +21,7 @@ For deployed services (observability, DNS, etc.) see [homelab-services](https://
2021
| `docs/patterns/runner-tiers.md` | The infra/service runner tier model |
2122
| `docs/nodes/terraform-runner/` | How to bootstrap the infra runner |
2223
| `docs/examples/new-node/` | Complete working example for a new VM node |
24+
| `docs/examples/new-vm-with-dns/` | Same, but also registers a `<name>.lan` A record |
2325
| `docs/troubleshooting/` | Common Proxmox provisioning and Ansible gotchas |
2426
| `docs/decisions/` | Architecture decision records |
2527
| `docs/observability/` | How observability agents work on every node |

docs/decisions/dns-management.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Decision: DNS Management in Terraform
2+
3+
> Status: Active
4+
5+
## Decision
6+
7+
DNS records are managed in Terraform via the `kenske/technitium` provider.
8+
Two pieces, split deliberately:
9+
10+
1. **`terraform/modules/dns-record/`** (this repo) — a thin reusable module
11+
wrapping the provider's `technitium_dns_zone_record`. Any Terraform root
12+
can compose it next to `module "proxmox-vm"`.
13+
2. **The `lan` primary zone** itself — created once and owned by
14+
`homelab-services/services/dns/terraform-config/`, never duplicated.
15+
16+
The reusable `.github/workflows/provision-vm.yml` workflow accepts a
17+
`create_dns_record: bool` input. When `true`, it loads
18+
`/technitium/` from Infisical alongside `/proxmox/`, `/terraform/`,
19+
`/ansible/`, and forwards `TECHNITIUM_HOST` / `TECHNITIUM_TOKEN` to the
20+
Terraform apply step as `TF_VAR_technitium_*`.
21+
22+
## Why composable, not integrated into `proxmox-vm`
23+
24+
`proxmox-vm` callers aren't all the same. Some VMs are ephemeral
25+
(test runners, throwaway experiments) and shouldn't pollute the
26+
authoritative DNS zone. Some homelab installations may not have Technitium
27+
at all and would still want to use `proxmox-vm`.
28+
29+
Integrating DNS into `proxmox-vm` would force every caller to either
30+
configure the `technitium` provider or special-case it with `count = 0`
31+
plumbing — friction for the common case. Keeping the modules separate
32+
lets callers opt in by writing two `module` blocks instead of one.
33+
34+
The reusable workflow's `create_dns_record` flag is the matching
35+
opt-in at the CI layer: non-DNS callers don't need `/technitium/` access in
36+
Infisical.
37+
38+
## Why the zone lives in `homelab-services`, not here
39+
40+
The zone is a singleton — its SOA, refresh interval, allowed-update
41+
policy, and existence are all single-point-of-truth concerns. If two roots
42+
declared `technitium_dns_zone "primary"`, the second `terraform apply`
43+
would fight the first.
44+
45+
`homelab-services` is the natural owner because the DNS *service*
46+
(Technitium itself) is deployed there. Records, on the other hand, live
47+
naturally with the thing they describe — the consumer's VM Terraform.
48+
49+
## Why `kenske/technitium`
50+
51+
Surveyed four community Technitium providers in May 2026:
52+
53+
| Provider | Scope | Verdict |
54+
|----------|-------|---------|
55+
| `kenske/technitium` | zones, records, DHCP scopes, DHCP reservations | **chosen** — broad enough, room to grow into DHCP |
56+
| `kevynb/technitium` | records only (no zones) | too narrow |
57+
| `darkhonor/technitium` | zones, records, block lists, server settings, STIG mode | STIG opinions add overhead a homelab won't use |
58+
| `chinyongcy/technitium` | zones, records | smaller surface than kenske, similar feature set |
59+
60+
Pinned to `~> 0.2.2` (latest as of Nov 2025). Single-maintainer risk is
61+
real; fallback if the provider goes unmaintained is direct API calls via
62+
`null_resource` + `local-exec curl` against
63+
[Technitium's HTTP API](https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md).
64+
65+
## Known gaps (managed in the UI for now)
66+
67+
`kenske/technitium` v0.2.2 does not model:
68+
69+
- **Forwarders / recursion settings** — Technitium's default (pure
70+
recursion to root servers) is what most homelabs want anyway. Set
71+
upstream forwarders via the UI if you need to.
72+
- **Block lists** — UI: DNS → Blocked Zones → Block Lists.
73+
74+
Both change rarely. If they ever need code-management, the
75+
`null_resource` + Technitium API escape hatch is the path.
76+
77+
## Bootstrap is one-time and manual
78+
79+
Terraform cannot mint its own API token; a human must log in once to the
80+
Technitium UI to create the `terraform` user and generate a token. The
81+
runbook lives at
82+
[`homelab-services/services/dns/BOOTSTRAP.md`](https://github.com/BlakeHastings/homelab-services/blob/main/services/dns/BOOTSTRAP.md).
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Example: new VM that also registers a DNS A record
2+
3+
Mirrors [`new-node/`](../new-node/) but composes the `proxmox-vm` and
4+
`dns-record` modules in one Terraform root so a VM and its
5+
`<name>.lan` A record are created (and destroyed) together.
6+
7+
## Files
8+
9+
| File | Purpose |
10+
|------|---------|
11+
| `versions.tf` | Declares both `bpg/proxmox` and `kenske/technitium` provider requirements |
12+
| `providers.tf` | Configures both providers (technitium reads creds from variables) |
13+
| `variables.tf` | All TF_VAR_* inputs, including `technitium_host` / `technitium_token` |
14+
| `backend.tf` | Local state on terraform-runner |
15+
| `main.tf` | `module "vm"` then `module "dns"` — wires `module.vm.vm_ip` into the DNS record |
16+
| `provision.yml` | Example workflow — sets `create_dns_record: true` on the reusable `provision-vm.yml` |
17+
18+
## How it differs from `new-node/`
19+
20+
1. Adds the `kenske/technitium` provider.
21+
2. Adds a `module "dns"` block alongside `module "vm"`.
22+
3. Workflow passes `create_dns_record: true`, which tells
23+
`provision-vm.yml@main` to also load `/technitium/` from Infisical and
24+
expose `TECHNITIUM_HOST` / `TECHNITIUM_TOKEN` to the Terraform apply.
25+
26+
## Prerequisites
27+
28+
- The `lan` zone must already exist in Technitium. It's created and owned
29+
by [homelab-services/services/dns/terraform-config/](https://github.com/BlakeHastings/homelab-services/tree/main/services/dns/terraform-config).
30+
- The Infisical machine identity for the calling repo must have **read**
31+
on the `/technitium/` path (one-time grant per repo).
32+
33+
See [`docs/decisions/dns-management.md`](../../decisions/dns-management.md)
34+
for the design rationale.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
terraform {
2+
backend "local" {
3+
# REPLACE: my-vm with your VM name. State lives on the terraform-runner.
4+
path = "/home/ubuntu/terraform-state/my-vm/terraform.tfstate"
5+
}
6+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# my-vm — example VM that also registers itself in DNS as my-vm.lan
2+
#
3+
# REPLACE: vm_name, vm_id, mac_address, dns_hostname for your VM.
4+
#
5+
# Lifecycle is unified: `terraform destroy` removes both the VM and its
6+
# DNS record. The "lan" zone itself is NOT created here — it's owned by
7+
# homelab-services/services/dns/terraform-config/.
8+
9+
module "vm" {
10+
source = "github.com/BlakeHastings/homelab-platform//terraform/modules/proxmox-vm?ref=main"
11+
12+
vm_name = "my-vm" # REPLACE
13+
vm_id = 202 # REPLACE: unique Proxmox VMID
14+
target_node = var.proxmox_node
15+
template_id = var.template_id
16+
cpu_cores = var.cpu_cores
17+
memory_mb = var.memory_mb
18+
disk_gb = var.disk_gb
19+
mac_address = "BC:24:11:00:02:02" # REPLACE: unique MAC for DHCP reservation
20+
dns_servers = [var.gateway] # use router for DNS during provisioning
21+
ssh_public_key = var.ssh_public_key
22+
}
23+
24+
module "dns" {
25+
source = "github.com/BlakeHastings/homelab-platform//terraform/modules/dns-record?ref=main"
26+
27+
hostname = module.vm.vm_name # → my-vm.lan
28+
ip = module.vm.vm_ip
29+
zone = "lan"
30+
}
31+
32+
output "vm_ip" {
33+
description = "VM IP — also reachable as ${module.dns.fqdn} once DNS records propagate."
34+
value = module.vm.vm_ip
35+
}
36+
37+
output "vm_id" {
38+
value = module.vm.vm_id
39+
}
40+
41+
output "vm_name" {
42+
value = module.vm.vm_name
43+
}
44+
45+
output "fqdn" {
46+
description = "DNS name the VM is reachable at on the LAN."
47+
value = module.dns.fqdn
48+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Proxmox provider — credentials from /proxmox/ in Infisical (loaded by the
2+
# reusable provision-vm workflow). See env vars: PROXMOX_VE_ENDPOINT etc.
3+
provider "proxmox" {
4+
insecure = true # homelab self-signed TLS certificate
5+
}
6+
7+
# Technitium provider — credentials from /technitium/ in Infisical, loaded
8+
# automatically by provision-vm.yml when create_dns_record: true.
9+
# Variables are populated via TF_VAR_technitium_host / TF_VAR_technitium_token
10+
# set by the workflow.
11+
provider "technitium" {
12+
host = var.technitium_host
13+
token = var.technitium_token
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Example: Provision my-vm and register my-vm.lan in one shot.
2+
#
3+
# Copy this to your project repo as .github/workflows/provision-my-vm.yml
4+
# and update the REPLACE values.
5+
#
6+
# Prerequisites:
7+
# 1. Proxmox Ubuntu 24.04 template exists (VMID 9000)
8+
# 2. terraform-runner VM bootstrapped (see homelab-platform docs)
9+
# 3. The "lan" zone exists in Technitium (provisioned once by
10+
# homelab-services/services/dns/terraform-config/)
11+
# 4. This repo has GitHub Actions Variables INFISICAL_IDENTITY_ID and
12+
# INFISICAL_PROJECT_SLUG set
13+
# 5. The Infisical machine identity for this repo has read on
14+
# /proxmox/, /terraform/, /ansible/, AND /technitium/
15+
#
16+
# Trigger manually: Actions → Provision my-vm → Run workflow
17+
18+
name: Provision my-vm # REPLACE
19+
20+
on:
21+
workflow_dispatch:
22+
23+
permissions:
24+
id-token: write # OIDC for Infisical auth
25+
contents: read
26+
27+
jobs:
28+
provision:
29+
uses: BlakeHastings/homelab-platform/.github/workflows/provision-vm.yml@main
30+
with:
31+
vm_name: "my-vm" # REPLACE
32+
cpu_cores: 2 # REPLACE if needed
33+
memory_mb: 4096 # REPLACE if needed
34+
disk_gb: 40 # REPLACE if needed
35+
terraform_working_dir: "terraform/nodes/my-vm" # REPLACE
36+
observability_server_ip: "192.168.0.251"
37+
create_dns_record: true # ← enables /technitium load
38+
infisical_identity_id: ${{ vars.INFISICAL_IDENTITY_ID }}
39+
infisical_project_slug: ${{ vars.INFISICAL_PROJECT_SLUG }}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# All variables are supplied via TF_VAR_* environment variables from the
2+
# reusable provision-vm workflow. Do not commit a .tfvars file.
3+
4+
variable "proxmox_node" {
5+
description = "Proxmox node name (e.g., pve)"
6+
type = string
7+
}
8+
9+
variable "template_id" {
10+
description = "VMID of the Ubuntu 24.04 cloud-init template"
11+
type = number
12+
default = 9000
13+
}
14+
15+
variable "gateway" {
16+
description = "Network gateway IP (e.g., 192.168.0.1)"
17+
type = string
18+
}
19+
20+
variable "ssh_public_key" {
21+
description = "SSH public key for VM access"
22+
type = string
23+
sensitive = true
24+
}
25+
26+
variable "cpu_cores" {
27+
type = number
28+
default = 2
29+
}
30+
31+
variable "memory_mb" {
32+
type = number
33+
default = 4096
34+
}
35+
36+
variable "disk_gb" {
37+
type = number
38+
default = 40
39+
}
40+
41+
# --- Technitium (DNS) credentials ---
42+
# Populated by provision-vm.yml when create_dns_record: true.
43+
44+
variable "technitium_host" {
45+
description = "Technitium DNS server base URL (e.g. http://192.168.0.250:5380)"
46+
type = string
47+
}
48+
49+
variable "technitium_token" {
50+
description = "API token for the 'terraform' Technitium user"
51+
type = string
52+
sensitive = true
53+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
terraform {
2+
required_version = ">= 1.5.0"
3+
4+
required_providers {
5+
proxmox = {
6+
source = "bpg/proxmox"
7+
version = "~> 0.99.0"
8+
}
9+
technitium = {
10+
source = "kenske/technitium"
11+
version = "~> 0.2.2"
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)