Skip to content

TLS certificates

DTM terminates TLS in two places, and they have different certificate lifecycles. This page covers what serves which certificate, what a self-signed certificate means for your clients, the two-year clock on the first-boot certificate, how to watch expiry, and how to rotate or bring your own.

Surface Port(s) Certificate
Web UI 8080 The managed UI certificate at /etc/dtm/tls/ on each node: self-signed with 2-year validity at first boot and renewed automatically, or one you supply.
REST API 8443 A per-node self-signed certificate written once at first boot and kept across restarts (2-year validity, renewed automatically), or one you upload.
DNS-over-TLS and DNS-over-HTTPS 853, 443 Share the API’s certificate material; there is no separate DoT or DoH certificate.

How the UI certificate is provisioned depends on how you deployed:

  • Terraform customer-starter environment: the primary node generates the certificate and key at first boot and publishes them to Key Vault as the dtm-tls secret (a single combined PEM bundle, certificate plus key); every node reads the same bundle, so the whole cluster serves one UI certificate. Or, if you supply a CA-signed certificate, every node reads yours from the vault secret you named instead, and the primary generates nothing. Either way Terraform never handles the certificate or its key.
  • Azure Marketplace, and the copy-ready Terraform and Bicep samples: each node generates its own self-signed UI certificate at first boot, with the node’s IP as the subject alternative name.

The dtm-tls bundle carries only the web UI certificate. The API certificate is separate on every deployment path: each node’s own bootstrap pair, or the one certificate you upload.

Unless you supply your own, every DTM TLS listener serves a self-signed certificate: no certificate authority vouches for it, so a client that verifies TLS has nothing to check it against. What that means depends on the client.

Plain DNS on port 53 is unaffected. It has no TLS, so ordinary resolvers and VNet clients never see a certificate. DoT (853) and DoH (443) are opt-in and ship disabled, so the question arises only once you turn them on, and for anything that calls the API on 8443.

Client Against a self-signed certificate
systemd-resolved with DNSOverTLS=yes Strict: verifies the certificate against the system trust store and refuses an untrusted one.
systemd-resolved with DNSOverTLS=opportunistic, or any other opportunistic DoT or DoH client Does not verify the certificate: queries are encrypted, but the server is not authenticated.
Windows 11 encrypted DNS, set to require encryption Verifies against the Windows certificate store; an untrusted certificate fails.
Browser secure DNS (DoH) Verifies against the browser’s or the operating system’s trust store; an untrusted certificate fails.
A resolver or forwarder that forwards to DTM over DoT or DoH Fails unless the certificate, or the CA that issued it, is pinned or in its trust store. A DTM forwarder pins one with ca_pem.
curl and other API scripts against 8443 Fail unless given the certificate or its CA (curl --cacert <file>), or told to skip verification.
The DTM Terraform provider Verifies against the trust store of the machine running Terraform, or, from 1.0.2, against the CA you give it in ca_cert_pem. That is a pin: verification stays on even if insecure_skip_verify is also set.
A browser opening the web UI on 8080 Shows a certificate warning.

Skipping verification (curl -k, which the examples in these docs use so they work on a fresh cluster, or the provider’s insecure_skip_verify = true) is for testing only: it accepts any certificate, including an impostor’s.

Importing or pinning a self-signed certificate on clients works, but each client then trusts that exact certificate, so you must import or pin the new one on every client whenever the certificate changes. The per-node bootstrap certificates make this harder: each node has its own, a rebuilt node writes a new one, and each renews itself about every 23 months (as a new certificate, over the same key since v1.0.6).

Section titled “Recommended: a certificate your clients already trust”

Supply a certificate issued by your internal PKI (a CA your clients already trust) or by a publicly trusted CA. Clients then verify DTM with nothing to import, and replacing the certificate later needs no client change as long as the same CA issues it. The certificate needs:

  • Subject alternative names for every name and address clients use: each node’s DNS name and IP (for example dtm1.example.internal and 10.0.250.4), plus any shared name you publish. A DoT or DoH client checks the name it is configured with, so that name must be in the list. It does not need 127.0.0.1: each node’s web UI reaches its own API over loopback without checking names.
  • PEM encoding: the leaf certificate first, then any intermediates, and the matching private key, unencrypted. A key that does not match is refused.
  • PEM only. A PKCS#12 file (.pfx or .p12) is refused with a 400 and tls: failed to find any PEM data in certificate input. Convert it first with openssl pkcs12 -in cert.pfx -nokeys -out chain.pem and openssl pkcs12 -in cert.pfx -nocerts -nodes -out key.pem, then check that chain.pem lists the leaf certificate first. If OpenSSL 3 refuses an older PFX with an unsupported-algorithm error, add -legacy to both commands.
  • An RSA or ECDSA key, which encrypted-DNS clients and browsers broadly support (the bootstrap certificates use ECDSA P-256).
  • Current validity: the API refuses a certificate that has expired or is not yet valid.

Where it goes:

  • API, DoT and DoH (one certificate serves 8443, 853 and 443): upload it once with PUT /api/v1/config/tls-certificate, as in Give the API a stable certificate. It needs the admin role and X-DTM-CSRF: 1; every node receives it over replication and serves it from its next full handshake, with no restart. The response describes the stored certificate (subject, DNS names, sha256_fingerprint, never the key) and what the node you called did with it. After the first upload the cluster keeps serving the stored certificate, across restarts: there is no way to remove it and return to the per-node bootstrap certificates, so to move off it, upload another. From Terraform (added in 1.0.2), the provider’s dtm_tls_certificate resource makes the same upload. Its key_pem_wo is write-only (Terraform 1.11 or later), so the key never reaches Terraform state, and terraform destroy removes the resource from state only: the cluster keeps serving the certificate. Set the provider’s ca_cert_pem to your CA’s PEM so it verifies the new certificate.
  • Web UI (8080, a separate certificate): in the Terraform customer-starter environment, put the bundle in the cluster’s Key Vault and name it in dtm_ui_tls_secret_name (at deploy time), or rotate onto it later. Marketplace and sample deployments have no Key Vault path for the UI certificate: install it on each node as in per-node certificates.

Alternative: your own self-signed certificate

Section titled “Alternative: your own self-signed certificate”

If you have no CA to issue one, you can create a self-signed certificate yourself. This is not the recommended route, because every caveat above still applies, but it gives the cluster one certificate you control instead of a different one per node. One openssl command (OpenSSL 1.1.1 or later) makes the pair, with the node names and IPs as subject alternative names:

Terminal window
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-days <days> -keyout api-key.pem -out api-chain.pem \
-subj "/CN=dtm.example.internal" \
-addext "subjectAltName=DNS:dtm1.example.internal,DNS:dtm2.example.internal,DNS:dtm3.example.internal,IP:10.0.250.4,IP:10.0.250.5,IP:10.0.250.6"

Then:

  • For the API, DoT and DoH, upload api-chain.pem and api-key.pem with the same PUT as in Give the API a stable certificate.
  • For the web UI, combine the pair into one bundle (cat api-chain.pem api-key.pem > bundle.pem) and use it as the Key Vault secret in the customer-starter environment, or install the two files on each node in a Marketplace or sample deployment.

Clients that verify must still trust it: add api-chain.pem to their trust store, pass it to curl --cacert, or pin it (a DTM forwarder’s ca_pem). When you replace the certificate, redistribute the new one to every one of those clients before or as you upload it.

The first-boot UI certificate is valid for 730 days. From 1.0.2, DTM renews its own self-signed UI certificate before it expires: once it is inside its last 30 days, or already expired, it is replaced by a fresh 730-day pair with the same subject alternative names, and the UI serves the new pair from its next full handshake, with no restart and no shell.

  • Azure Marketplace, and the Terraform and Bicep samples: each node checks its own certificate daily (the dtm-ui-tls-renew.timer unit, ten minutes after boot and then daily).
  • Terraform customer-starter environment: the primary node renews the default dtm-tls bundle in Key Vault, as a new version of the secret (it checks about hourly), and every node applies the new version within the hour.

Only DTM’s own self-signed certificate is renewed: its subject and issuer must both be exactly CN=dtm-ui, and it must be signed by its own key. A certificate you supplied (installed on a node, or named in dtm_ui_tls_secret_name) is never renewed, however close it is to expiry. When it expires, browsers refuse the UI outright (a harder stop than the self-signed warning), and nothing in DTM warns about the UI certificate first, so put its expiry date where your team plans work and rotate before it. The one exception is a self-signed certificate you make with exactly CN=dtm-ui as its subject, as in the rotation example below: nothing tells it apart from DTM’s own, so it is renewed the same way.

A renewal is a new key. A browser or client that trusts the self-signed UI certificate itself, rather than a CA, must trust the new one after each renewal, roughly every 23 months. A corporate-CA certificate has none of this, and makes the browser warning disappear too. Nodes deployed before 1.0.2 renew once they are replaced with a 1.0.2 or later image, which every supported upgrade does.

The API’s bootstrap certificate follows the same policy from 1.0.4. Each node writes a self-signed pair to /var/lib/dtm/api-tls.crt and api-tls.key at first boot and keeps it across restarts (a node rebuilt on a fresh data disk, as the stable-IP swap does, writes a new one). It is valid for 730 days, and dtm-server replaces it with a fresh 730-day pair once it is inside its last 30 days, or already expired. It checks at startup and then hourly, and the API, DoT and DoH serve the new pair from their next full handshake, with no restart. The node’s journal records each renewal as a TLS renew: outcome=renewed line with the old and new expiry dates. A renewal of the on-disk pair also logs the old and new SHA-256 fingerprints. A node that ran an earlier release keeps its one-year certificate until that reaches its last 30 days, then renews it the same way.

Only the bootstrap certificate is renewed: one whose subject and issuer are exactly O=DTM Self-Signed, CN=dtm-server, signed by its own key. An uploaded certificate is never renewed, even a self-signed one, and neither are your own certificate files. A bootstrap pair whose certificate and key do not match is left alone and logged as outcome=refused.

Changed in v1.0.6. A renewal issues a new certificate over the same key, so the node’s public key and its SPKI hash stay the same, and a client that pins only the public key keeps trusting the node. Anything that trusts the bootstrap certificate itself, rather than a CA, still stops verifying that node after each renewal: a workstation that added it to its trust store, a forwarder in another cluster that pins it with ca_pem, or any client that pinned the certificate. Each must fetch the new certificate again (compare it with the SHA-256 in the journal line). No CA vouches for the bootstrap certificate, which is exactly why the examples in these docs use curl -sk and why production clients that verify TLS should give the API a stable certificate: a CA-issued certificate has none of this.

On a node (the certificate directory is group-restricted, so the read needs sudo):

Terminal window
sudo openssl x509 -in /etc/dtm/tls/cert.pem -noout -enddate

Or from anywhere that can reach the UI:

Terminal window
openssl s_client -connect <node-ip>:8080 </dev/null 2>/dev/null \
| openssl x509 -noout -enddate

The Diagnose page also runs a certificate-validity check against the API certificate file (on a deployed node, the bootstrap certificate or the one you uploaded): it warns when the certificate is within 30 days of expiry and fails once it has expired, so it shows up in routine health reviews and in support bundles.

From 1.0.4, every node also reports dtm_tls_certificate_expires_in_seconds for each TLS listener (listener="api", or "dns" for DoT and DoH, which share one certificate): the seconds left on the certificate it presents, whoever supplied it, negative once it has expired. The DTMTLSCertificateExpiringSoon alert, and the matching rule in the Azure Monitor alerting samples, fire below 21 days. On the bootstrap certificate that means renewal has been failing for over a week; on one you supplied, it is your notice to replace it. Neither covers the web UI certificate.

Terraform customer-starter environment (one cluster-wide certificate)

Section titled “Terraform customer-starter environment (one cluster-wide certificate)”
  1. Produce a new combined PEM bundle: the certificate and its private key concatenated into one file. From your CA, or self-signed again:

    Terminal window
    openssl req -x509 -newkey rsa:4096 -nodes -days 730 \
    -keyout key.pem -out cert.pem \
    -subj "/CN=dtm-ui" -addext "subjectAltName=DNS:dtm-ui"
    cat cert.pem key.pem > bundle.pem
  2. Add the new bundle as a new version of the vault secret the nodes read: dtm-tls, or, if you set dtm_ui_tls_secret_name, the secret it names. This is a manual step outside Terraform, and it needs the same vault role and network reach as supplying the certificate:

    Terminal window
    az keyvault secret set --vault-name <vault> --name dtm-tls --file bundle.pem
  3. Wait for the nodes to apply it. Each node re-reads the secret about once an hour, and two minutes after every boot. It checks that the key matches the certificate and that the certificate has not expired, then installs the pair with the right ownership, and the UI serves it from its next full handshake, with no restart and no shell. A mismatched or expired bundle is refused, and the node keeps serving its current certificate. (Nodes deployed before 1.0.2 read the secret only when their certificate files are missing; the upgrade that replaces them brings the hourly check.)

    To apply it sooner, restart the nodes one at a time: each applies the secret two minutes after it boots. A restarting node stops answering DNS until it is back, so wait for each to report ready before the next, as in a rolling upgrade.

Waiting for the hourly check never touches DNS. The nodes follow the secret in both directions: a certificate placed on a node by hand is replaced by the secret’s at the next check. After a key compromise, finish with a rolling restart anyway: a client resuming an earlier TLS session keeps the old certificate until the UI process restarts.

Marketplace and sample deployments (per-node certificates)

Section titled “Marketplace and sample deployments (per-node certificates)”

Each node manages its own pair at /etc/dtm/tls/. On each node, either install your own certificate and key over the existing files, or delete both files and restart dtm-bootstrap.service to mint a fresh two-year self-signed pair; then restart dtm-ui. If you install files by hand, match the expected ownership, since the UI refuses to start on a group- or world-readable private key:

Terminal window
sudo chown root:dtm-ui /etc/dtm/tls/cert.pem && sudo chmod 0640 /etc/dtm/tls/cert.pem
sudo chown dtm-ui:dtm-ui /etc/dtm/tls/key.pem && sudo chmod 0400 /etc/dtm/tls/key.pem
sudo systemctl restart dtm-ui

From 1.0.2 the node also renews its own self-signed pair; a certificate you install over it is never replaced.

Enterprise deployments usually want the UI on a certificate their browsers already trust:

  • At deploy time (Terraform customer-starter environment): put the bundle in the cluster’s Key Vault yourself and give Terraform only the secret’s name, as below. The cluster boots directly onto your certificate instead of self-signing.
  • After deploy: follow the rotation procedure with your CA-issued bundle.

Issue the certificate with subject alternative names covering however your operators reach the UI (a DNS name you publish for the nodes, or the node IPs).

  1. Prepare one PEM file: the leaf certificate, then any intermediates, then the matching private key, unencrypted. Each node checks that the key matches the certificate and refuses a mismatch.

  2. Create the secret in the cluster vault, under any valid secret name (letters, digits and hyphens):

    Terminal window
    az keyvault secret set --vault-name <vault> --name dtm-ui-ca-cert --file bundle.pem
    • Role: you need a data-plane role on the vault, for example Key Vault Secrets Officer, granted by someone with Owner or User Access Administrator on it.
    • Network: with a Private vault, run the command from a host that resolves and reaches the vault’s private endpoint: a jump host or Azure Bastion session in (or peered to) the VNet, or a pipeline agent inside it. The Azure portal works only from a network that resolves privatelink.vaultcore.azure.net.
  3. Set the name in your variables: dtm_ui_tls_secret_name = "dtm-ui-ca-cert".

The order matters, because the nodes read the secret when they first boot:

  • A vault you brought (create_key_vault = false): create the secret, then apply.

  • The vault the environment creates: create the vault first, add the secret, then apply the rest:

    Terminal window
    terraform apply -target=azurerm_private_endpoint.kv # the vault, its endpoint and DNS
    # create the secret (step 2), then:
    terraform apply

    With ServiceEndpoint or Public vault access, -target='azurerm_key_vault.dtm[0]' is enough.

A missing secret is a named failure, not a fallback. A node never serves a self-signed certificate in place of yours. Instead, DTM does not start on that node (it never reports ready), and its bootstrap log (journalctl -u dtm-bootstrap, over break-glass OS access) says:

could not read the customer-supplied TLS secret '<name>' from vault '<vault>'

dtm-bootstrap retries about once a minute until it succeeds, with no time limit, so a secret created afterwards is picked up on its next attempt. If a node is still not ready a few minutes after the secret exists, restart that VM:

Terminal window
az vm restart --resource-group <rg> --name <vm>

The bootstrap runs again from the start at boot and reads the secret. A restart needs no shell and no open port.

Earlier versions of the customer-starter environment took the certificate itself, in the write-only dtm_ui_tls_pem_wo variable, and wrote it into the dtm-tls secret. If you set it, remove dtm_ui_tls_pem_wo and dtm_ui_tls_pem_wo_version from your variables. terraform plan then shows:

  • the dtm-tls secret leaving Terraform’s management without being destroyed: your certificate stays in the vault;
  • the vault role the deploying identity held for that write being removed;
  • no change to the nodes. With dtm_ui_tls_secret_name unset, the primary generates a certificate only when dtm-tls is absent, and yours is there, so the nodes keep serving it.

Optionally, also set dtm_ui_tls_secret_name = "dtm-tls". A missing certificate then becomes the named failure above rather than a freshly generated self-signed one. That changes each node’s boot configuration, so Terraform replaces the nodes once.

Each node’s bootstrap certificate is self-signed and different on every node, so API clients that verify TLS (including the Terraform provider) cannot trust it. Give the cluster one CA-issued certificate instead: upload it once, to any node, and every node serves it. With $DTM_TOKEN holding an admin bearer token (how to capture one):

Terminal window
jq -n --rawfile cert api-chain.pem --rawfile key api-key.pem \
'{cert_pem: $cert, key_pem: $key}' \
| curl -sk -X PUT https://<node-ip>:8443/api/v1/config/tls-certificate \
-H "Authorization: Bearer $DTM_TOKEN" \
-H "X-DTM-CSRF: 1" \
-H "Content-Type: application/json" \
-d @-

cert_pem is the certificate in PEM form, leaf first, followed by any intermediates; key_pem is its unencrypted PEM private key. An upload whose key does not match the certificate, or whose certificate has expired or is not yet valid, is refused with a 400. The private key is write-only: no response returns it, it is redacted from the audit log, and it is stored encrypted with the cluster encryption key.

The node you call writes the pair to its certificate files at once, the other nodes receive it over cluster replication straight away (a node that misses it catches up automatically within seconds), and each listener serves it from its next full TLS handshake. No restart is needed, so DNS never stops. A node that an upgrade replaces later receives the certificate in its first sync and serves it, so nothing needs re-installing after an upgrade.

Check that it landed on every node: the node you uploaded to cannot see the others, and a node that refuses the material keeps serving its previous certificate. Each node reports for itself (and during a first rollout, a node still presenting its self-signed certificate fails the --cacert check outright):

Terminal window
curl -s --cacert corp-ca.pem https://<node>:8443/api/v1/config/tls-certificate \
-H "Authorization: Bearer $DTM_TOKEN" \
| jq '{not_after, this_node, stored_pair}'

Expect this_node.outcome to be written or unchanged, this_node.matches_stored to be true, and stored_pair.usable to be true. this_node covers the last certificate the node applied since it started, so it can be absent after a restart; there, compare the expiry the node serves (openssl s_client piped to openssl x509 -noout -enddate, as in Check expiry, on port 8443) with the response’s not_after.

To renew, upload the renewed certificate the same way. After a key compromise, upload the replacement and then restart dtm-server one node at a time anyway, gating each on /readyz as in a rolling upgrade: a client resuming an earlier TLS session skips certificate selection and keeps the old certificate until the process restarts.

The upload relies on listen.tls.managed: true with cert_file and key_file set, which every DTM deployment writes (/var/lib/dtm/api-tls.crt and /var/lib/dtm/api-tls.key). A node whose configuration drops managed stores the cluster’s certificate but keeps serving its own.

The DNS-over-TLS and DNS-over-HTTPS listeners serve the same certificate, so uploading it once also gives resolvers a verifiable certificate on ports 853 and 443. Include every node’s address (or a shared DNS name) in the certificate’s subject alternative names.

To manage the files yourself, point cert_file and key_file at them in /etc/dtm/dtm-server.yaml on each node and remove managed: true: the managed paths belong to the cluster, which writes uploaded certificates over them, so a node configured this way does not serve an uploaded certificate. Both fields must be set together. dtm-server runs as the unprivileged dtm user, so install the files for it, in a directory such as /etc/dtm/api-tls (root:dtm, mode 0750): the certificate dtm:dtm mode 0644, and the key dtm:dtm mode 0600. A group- or world-readable key, or a missing or unreadable file, stops startup with a clear error rather than silently falling back to self-signing.

Changing the paths takes a restart, because the configuration is read at startup: restart dtm-server one node at a time, since a node stops answering DNS while it restarts. After that, replacing the files at the same paths takes effect on the next full handshake with no restart (a key compromise still calls for the rolling restart above). Everything you change by hand on the operating-system disk (certificate files under /etc/dtm, and the edit to dtm-server.yaml itself) is gone after any upgrade, because every supported upgrade replaces that disk: redo it on each node afterwards.

  • Rotating secrets: the rest of the cluster’s credential lifecycle in one place.
  • Security: the full protections-and-responsibilities picture.