Skip to content

Terraform provider setup

The DTM Terraform provider is the recommended way to manage DTM. It turns zones, records, GSLB, health checks, forwarders, and cluster settings into declarative, reviewable, version-controlled HCL.

This page covers installing the provider, configuring the connection, and authenticating. The rest of the Terraform section covers the individual resources.

The provider manages DTM’s DNS data plane and cluster configuration:

  • Zones and records: dtm_zone, dtm_record, dtm_override_record.
  • Traffic management and health: dtm_healthcheck, plus the load-balancing and ALIAS/GSLB settings carried on records.
  • Forwarding and resolver policy: dtm_forwarder, dtm_default_forwarder, dtm_blocklist, dtm_tsig_key (and stub zones).
  • Regions and discovery: dtm_discovery_scope and subnet mappings.
  • Cluster and runtime config singletons: dtm_axfr_config, dtm_cache_config, dtm_dynamic_dns_config, dtm_forwarder_hardening_config, dtm_otlp_config, dtm_query_log_config, dtm_rate_limit_config, dtm_replication_config, and dtm_transport_config.

See the resource reference for the complete list with required roles and import formats.

Users, RBAC, and Entra SSO are manageable either way: as code with dtm_user and dtm_sso_config (see users and SSO as code) or via the web UI and REST API. Two things are deliberately not Terraform resources:

  • Backup and restore operations. See backup and restore.
  • The cluster’s bootstrap secrets: the initial administrator password, the cluster encryption key, and the management TLS material. These are established at deploy time rather than through DTM’s Terraform resources (see the note below).

The provider ships inside the DTM image. Every node carries the provider for each supported workstation platform, at the same version as the DTM server running beside it, so the two cannot drift apart. It is not on a public registry and there is no separate download: your cluster is the source.

terraform init fetches it from the cluster over HTTPS, which takes two steps: turn the mirror on, and make sure Terraform can verify the cluster’s certificate.

1. Give the API a certificate Terraform can verify

Section titled “1. Give the API a certificate Terraform can verify”

Do this first, because the rest of the page assumes it. Terraform checks the mirror’s TLS certificate against your workstation’s trust store, and provider installation has no option to skip that check. By default the DTM API presents a self-signed certificate that it regenerates every time the service starts, which no workstation will trust.

Give the API a stable certificate, and include the address you will point Terraform at in its subject alternative names: TLS certificates. If your own certificate authority issued it, trust that authority on the workstation running Terraform, as you would for any internal service.

The mirror is off by default, and the endpoints it adds are unauthenticated by protocol, so leaving it off until you want it is deliberate. Enable it on any node and the whole cluster follows, with no restart:

Terminal window
curl -s -X PUT https://<node-ip>:8443/api/v1/config/terraform-mirror \
-H "Authorization: Bearer $DTM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"enabled": true}'

Setting it back to false turns the endpoints off again the same way. (If you have not done step 1 yet, curl needs -k here to reach the API at all, which is the same trust gap Terraform will hit.)

Generate the Terraform CLI configuration from any node:

Terminal window
dtm-server --print-terraformrc

Copy the printed provider_installation block into ~/.terraformrc (or %APPDATA%\terraform.rc on Windows), adjusting the URL to an address your workstation can reach and that the certificate covers. terraform init then installs the provider from the cluster with no registry access.

Then declare the provider in a required_providers block:

terraform {
required_providers {
dtm = {
source = "dtm.local/dtm/dtm"
version = "~> 0.1"
}
}
}

Run terraform init to install it.

The provider needs two things: the endpoint (the DTM API base URL) and a token (a JWT bearer token).

provider "dtm" {
endpoint = "https://dtm.internal:8443"
token = var.dtm_token
}
Attribute Required Description
endpoint Yes Base URL of the DTM API, e.g. https://dtm.internal:8443. Falls back to the DTM_ENDPOINT environment variable.
token Yes JWT bearer token from POST /api/v1/auth/login. Sensitive. Falls back to the DTM_TOKEN environment variable.
insecure_skip_verify No Skip TLS certificate verification. Only for dev clusters with self-signed certificates; leave off in production.

The cleanest pattern is to keep the endpoint and token out of your HCL entirely and pass them as environment variables:

Terminal window
export DTM_ENDPOINT="https://dtm.internal:8443"
# Login returns the JWT only as the dtm_token cookie (not in the body);
# capture it from the cookie jar:
export DTM_TOKEN=$(curl -sk -c - -X POST "$DTM_ENDPOINT/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"username": "terraform", "password": "'"$DTM_PASSWORD"'"}' \
-o /dev/null | awk '$6 == "dtm_token" {print $7}')
# No credentials in HCL - the provider reads DTM_ENDPOINT and DTM_TOKEN.
provider "dtm" {}

For unattended automation, a service account beats a user login: its dtmsat_ token is long-lived (up to 10 years, or non-expiring), it carries a least-privilege role of its own, and the pattern below keeps the token out of Terraform state entirely.

Two pieces work together (both need an admin-role connection):

  • dtm_service_account creates the account. Every configurable attribute (name, role, description, expires_in_days, zone_ids, rotation) forces replacement: there is deliberately no update path, so a “changed” credential is always a fresh credential (a scope change invalidates the current token exactly like a rotation bump; the ephemeral resource mints the fresh one in the same apply). The create-time token is discarded by the provider, never stored.
  • The ephemeral dtm_service_account_token mints the actual token at apply time via the version-gated rotate endpoint, and hands it to exactly one write-only sink. Ephemeral values exist only in memory during the run.
variable "token_version" {
type = number
default = 1
}
resource "dtm_service_account" "ci" {
name = "externaldns"
role = "editor"
# Bump this together with token_version to rotate.
rotation = tostring(var.token_version)
}
ephemeral "dtm_service_account_token" "ci" {
service_account_id = dtm_service_account.ci.id
version = var.token_version
}
resource "azurerm_key_vault_secret" "dtm_token" {
name = "dtm-token"
key_vault_id = var.kv_id
# value_wo is write-only: the token is read from the ephemeral resource at
# apply and is NEVER persisted to Terraform state.
value_wo = ephemeral.dtm_service_account_token.ci.token
value_wo_version = var.token_version
}

Your CI job then reads dtm-token from Key Vault and exports it as DTM_TOKEN.

To rotate, bump var.token_version and apply: the account is recreated (the rotation keeper changed), the ephemeral resource mints a fresh token for it, and the write-only sink re-sends the new value. Bumping only the ephemeral version against an unchanged account is the one anti-pattern to avoid: ephemeral resources also open during plan, so an in-place bump can burn the rotation at plan time and hand the apply an empty no-op token. Keeping the three versions moving together, as above, avoids it by construction.

Service accounts support admin, editor, and readonly roles, plus expires_in_days (0 or unset never expires; maximum 3650) and optional zone scoping: set zone_ids to confine the token’s writes to specific zones, the right shape for a per-team pipeline:

resource "dtm_service_account" "team_a_ci" {
name = "team-a-ci"
role = "editor"
zone_ids = [dtm_zone.team_a.id, dtm_zone.team_a_staging.id]
rotation = tostring(var.token_version)
}

Scope narrows the role, never widens it (a scoped admin token is still confined), reads are not zone-filtered, and rotation via the ephemeral resource preserves the scope. Revocation is available through the service-accounts API.

Each resource’s required role is noted on its reference page and in the resource reference. In short:

  • editor covers most data-plane resources: zones, records, ALIAS and override records, health checks, forwarders, stub zones, blocklist, subnet mappings, and TSIG keys.
  • admin is required for the runtime configuration singletons (observability, rate limits, cache, replication, AXFR, dynamic DNS; AXFR and query logging replicate cluster-wide, while observability, rate limits, cache, replication timing, and dynamic DNS configure only the node the provider endpoint targets) and for Azure discovery scopes.

If a resource returns a 403, the token’s user does not have the role that resource needs.

resource "dtm_zone" "example" {
name = "example.internal."
default_ttl = 300
}
resource "dtm_record" "web" {
zone_id = dtm_zone.example.id
name = "web"
type = "A"
ttl = 60
values { value = "10.0.1.10" }
values { value = "10.0.1.11" }
lb_policy {
method = "round-robin"
}
}
Terminal window
terraform init
terraform plan
terraform apply

Every resource with a server-assigned ID supports terraform import. The import ID format is noted per resource in the resource reference. Records, for example, import as <zone_id>:<record_id>:

Terminal window
terraform import dtm_record.web 8f3a...:c21b...