Managing DNS Records with deSEC.io and Terraform

rwxd July 01, 2026 #terraform #dns #desec.io

I manage all my DNS records using deSEC.io as the DNS provider and Terraform to define them as code. This keeps everything version-controlled, reviewable, and reproducible. OpenTofu works as well since the setup is fully compatible.

DNS Management Workflow

Why deSEC.io?

deSEC is a free, non-profit DNS hosting service based in Germany. It supports DNSSEC out of the box and has a clean REST API.

Repository Structure

The setup is minimal:

tf-desecio/
├── main.tf          # Terraform logic
├── providers.tf     # Provider configuration
├── records.yaml     # All DNS records
└── .forgejo/workflows/terraform.yaml  # CI pipeline

Provider Configuration

Using the Valodim/desec Terraform provider:

terraform {
  required_providers {
    desec = {
      source  = "Valodim/desec"
      version = "0.6.1"
    }
  }
}

Authentication is done via the DESEC_API_TOKEN environment variable.

Defining Records in YAML

All DNS records live in a single records.yaml file, grouped by domain:

example.com:
  - subname: ""
    type: "MX"
    records:
      - "10 mail.example.com."
  - subname: "mail"
    type: "A"
    records:
      - "203.0.113.1"
  - subname: "mail"
    type: "AAAA"
    records:
      - "2001:db8::1"
  - subname: ""
    type: "TXT"
    records:
      - "v=spf1 mx -all"

another-domain.de:
  - subname: "www"
    type: "CNAME"
    records:
      - another-domain.de.

This makes it easy to see all records at a glance and add new ones without touching any HCL.

Terraform Logic

The main.tf flattens the YAML structure into individual desec_rrset resources:

locals {
  raw_records = yamldecode(file("records.yaml"))

  records_flattened = flatten([
    for domain_name, record_set in local.raw_records : [
      for item in record_set : {
        domain  = domain_name
        subname = item.subname
        type    = item.type
        records = item.records
        ttl     = lookup(item, "ttl", 3600)
      }
    ]
  ])
}

resource "desec_rrset" "rrset" {
  for_each = { for rr in local.records_flattened : "${rr.domain}-${rr.subname}-${rr.type}" => rr }
  domain   = each.value.domain
  subname  = each.value.subname
  type     = each.value.type
  records  = each.value.records
  ttl      = each.value.ttl
}

Each record gets a unique key based on domain, subname, and type. Adding a new record is just appending a few lines to the YAML file.

CI Pipeline

A Forgejo Actions workflow runs on every push and PR:

on:
  push:
    branches: [main]
  pull_request:

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: hashicorp/setup-terraform@v4
      - run: terraform init
      - run: terraform fmt -check
      - run: terraform validate

This catches formatting issues and invalid configurations.

Workflow

Adding or changing a DNS record:

  1. Edit records.yaml
  2. Run terraform plan to verify changes
  3. Run terraform apply to push to deSEC
  4. Commit and push

That's it. No web UI clicking, no forgetting what you changed, and full git history of every DNS modification.