A Go tool for configuring Technitium DNS Server in a declarative fashion, supporting both one-time setup and continuous configuration management. Also supports password management, API token creation with Kubernetes secret sync, and DNS cluster orchestration.
DNS is critical and therefore its configuration should be easily repeatable. Technitium is one of the only open source, authoritative DNS servers with a UI that supports additional feature sets like DNS sinkhole, RFC 2136 (external-dns), split horizon, and more. However its config files are stored in binary with complex versioning/logic, and there isn't a declarative solution. To address this, an over-engineered configuration utility was born.
- Declarative DNS configuration — define your entire DNS setup in YAML
- DNS server settings — 100+ configurable parameters
- Zone management — Primary, Secondary, Stub, Forwarder, Catalog zones with ACLs
- Record management — All standard record types (A, AAAA, CNAME, MX, TXT, SRV, DS, SSHFP, TLSA, SVCB, HTTPS, URI, CAA, NAPTR, FWD, APP, and more)
- App installation & configuration — Advanced Blocking, Advanced Forwarding
- Cluster support — Declarative cluster init/join via YAML config, plus
cluster-statefor inspection - API token management — Create non-expiring tokens, store in file or Kubernetes secret
- Password management — Change user passwords
- Kubernetes-native — Token storage in K8s secrets, RBAC manifests, Job-based deployment
- Idempotent — Safe to run repeatedly; existing state is detected and preserved
Tested against Technitium DNS Server v14.x (v14.3+). Cluster features (init, join, setOptions) require v14.0+. Older versions may work for basic DNS settings, zones, and records but are not officially supported.
| Command | Description |
|---|---|
configure |
Apply DNS server configuration from a YAML file (including cluster setup) |
create-token |
Create an API token (saves to file and/or Kubernetes secret) |
change-password |
Change the password for the current user |
cluster-state |
Display the current cluster topology and health |
docker pull ashtonian/technitium-configurator:latestMulti-arch images are published for linux/amd64 and linux/arm64.
docker run --rm \
-e DNS_API_URL="http://your-dns-server:5380" \
-e DNS_USERNAME="admin" \
-e DNS_PASSWORD="admin" \
-e DNS_NEW_PASSWORD="new-secure-password" \
ashtonian/technitium-configurator:latest change-password# Save token to a file
docker run --rm \
-e DNS_API_URL="http://your-dns-server:5380" \
-e DNS_USERNAME="admin" \
-e DNS_PASSWORD="new-secure-password" \
-e DNS_TOKEN_PATH="/app/token.yaml" \
-v "$(pwd)/token.yaml:/app/token.yaml" \
ashtonian/technitium-configurator:latest create-token
# Or save token to a Kubernetes secret
docker run --rm \
-e DNS_API_URL="http://your-dns-server:5380" \
-e DNS_USERNAME="admin" \
-e DNS_PASSWORD="new-secure-password" \
-e DNS_K8S_SECRET_NAME="technitium-token" \
-e DNS_K8S_SECRET_NAMESPACE="default" \
ashtonian/technitium-configurator:latest create-tokendocker run --rm \
-e DNS_API_URL="http://your-dns-server:5380" \
-e DNS_API_TOKEN="your-token" \
-e DNS_CONFIG_PATH="/app/config.yaml" \
-v "$(pwd)/config.yaml:/app/config.yaml" \
ashtonian/technitium-configurator:latest configureCluster configuration is declarative — add a cluster section to your DNS config YAML and it will be applied during configure. DNS settings (including TSIG keys) are applied first, then cluster init/join, followed by zones and records. Only the primary node needs DNS settings, zones, and records; they replicate to secondary nodes via the cluster.
# Step 1: Configure the primary node first (cluster init + full DNS config)
cluster:
mode: "primary"
domain: "dns-cluster.example.com"
nodeIPs: "10.0.0.1"
# Cluster timing options (primary only):
configRefreshIntervalSeconds: 60 # how often secondaries sync config (default 900)
configRetryIntervalSeconds: 30 # retry interval on sync failure (default 60)
heartbeatRefreshIntervalSeconds: 15 # heartbeat interval (default 30)
heartbeatRetryIntervalSeconds: 10 # heartbeat retry interval (default 10)
dnsSettings:
# ...
zones:
# ...
records:
# ...# Step 2: Configure the secondary node (cluster join only, no DNS config needed)
# DNS settings, zones, and records replicate automatically from the primary.
cluster:
mode: "secondary"
nodeIPs: "10.0.0.2"
primaryURL: "https://primary-dns:5380"
primaryIP: "10.0.0.1"
primaryUsername: "admin"
primaryPassword: "password"
primaryTotp: "" # TOTP code for 2FA-enabled primaries
ignoreCertErrors: false# Step 1: Configure primary (cluster init + DNS settings + zones + records)
docker run --rm \
-e DNS_API_URL="http://primary-dns:5380" \
-e DNS_USERNAME="admin" \
-e DNS_PASSWORD="password" \
-v "$(pwd)/primary.yaml:/app/config.yaml" \
ashtonian/technitium-configurator:latest configure
# Step 2: Configure secondary (cluster join only — must run after primary)
docker run --rm \
-e DNS_API_URL="http://secondary-dns:5380" \
-e DNS_USERNAME="admin" \
-e DNS_PASSWORD="password" \
-v "$(pwd)/secondary.yaml:/app/config.yaml" \
ashtonian/technitium-configurator:latest configure
# Check cluster state
docker run --rm \
-e DNS_API_URL="http://primary-dns:5380" \
-e DNS_API_TOKEN="your-token" \
ashtonian/technitium-configurator:latest cluster-stateThe configurator supports two methods of configuration, with environment variables taking precedence over YAML:
- Environment Variables — all settings can be provided via env vars, no config files required
- YAML Configuration Files — traditional file-based configuration, can be mixed with env vars
| Variable | Required | Description |
|---|---|---|
DNS_API_URL |
Yes | URL of the DNS server API (e.g., http://dns-server:5380) |
DNS_API_TOKEN |
Conditional | API token for authentication |
DNS_USERNAME |
Conditional | Username for token creation and password change |
DNS_PASSWORD |
Conditional | Current password |
DNS_NEW_PASSWORD |
For change-password |
New password |
| Variable | Default | Description |
|---|---|---|
DNS_CONFIG_PATH |
config.yaml |
Path to DNS configuration file |
DNS_TOKEN_PATH |
Path to token file output | |
DNS_TIMEOUT |
30s |
API call timeout |
DNS_LOG_LEVEL |
info |
Logging level: debug, info, warn, error |
| Variable | Default | Description |
|---|---|---|
DNS_K8S_SECRET_NAME |
Name of Kubernetes secret to store token in | |
DNS_K8S_SECRET_NAMESPACE |
default |
Namespace for the Kubernetes secret |
DNS_K8S_SECRET_KEY |
api-token |
Key in Kubernetes secret to store token |
The configurator itself can be configured via YAML (separate from the DNS config):
api_url: "http://dns-server:5380"
api_token: "your-token"
username: "admin"
password: "your-password"
token_path: "/app/token.yaml"
timeout: 30s
log_level: "info"
k8s_secret_name: "technitium-token"
k8s_secret_namespace: "default"
k8s_secret_key: "api-token"The DNS config file defines your desired server state. See examples/config.yaml for a full reference.
# Optional: cluster configuration (applied after DNS settings).
# DNS settings are applied first so TSIG keys are set cleanly before
# cluster init adds its own internal keys.
# Only the primary needs dnsSettings/zones/records — they replicate
# to secondary nodes. Secondary config is cluster-only.
#
# Primary:
# cluster:
# mode: "primary"
# domain: "dns-cluster.example.com"
# nodeIPs: "10.0.0.1"
# configRefreshIntervalSeconds: 60
# heartbeatRefreshIntervalSeconds: 10
#
# Secondary (separate config file, no dnsSettings needed):
# cluster:
# mode: "secondary"
# nodeIPs: "10.0.0.2"
# primaryURL: "https://primary-dns:5380"
# primaryIP: "10.0.0.1"
# primaryUsername: "admin"
# primaryPassword: "password"
# primaryTotp: ""
# ignoreCertErrors: false
dnsSettings:
dnsServerDomain: "technitium.somedomain.com"
recursion: Deny
logQueries: false
loggingType: FileAndConsole
useLocalTime: true
maxLogFileDays: 7
maxStatFileDays: 365
qpmLimitRequests: 0
qpmLimitErrors: 0
enableDnsOverUdpProxy: true
enableDnsOverTcpProxy: true
enableDnsOverHttp: true
enableDnsOverTls: true
enableDnsOverHttps: true
enableDnsOverHttp3: true
enableDnsOverQuic: true
udpPayloadSize: 1232
resolverConcurrency: 4
forwarderConcurrency: 10
forwarderTimeout: 2000
forwarderRetries: 2
concurrentForwarding: true
cacheMaximumEntries: 0
serveStale: true
serveStaleTtl: 86400
cacheNegativeRecordTtl: 60
tsigKeys:
- keyName: "external-dns"
algorithmName: "hmac-sha256"
sharedSecret: "somesecret"
zones:
- zone: "somedomain.com"
type: "Forwarder"
initializeForwarder: true
protocol: "Udp"
forwarder: "172.0.0.1"
dnssecValidation: false
aclSettings:
queryAccess: AllowOnlyPrivateNetworks
zoneTransfer: UseSpecifiedNetworkACL
zoneTransferNetworkACL: ["172.0.0.0/8"]
zoneTransferTsigKeyNames: ["external-dns"]
update: "UseSpecifiedNetworkACL"
updateNetworkACL:
- "172.0.0.0/8"
updateSecurityPolicies: >
external-dns|*.somedomain.com|ANY
|external-dns|somedomain.com|ANY
- zone: "someotherdomain.com"
type: "Forwarder"
initializeForwarder: true
protocol: "Https"
forwarder: "https://cloudflare-dns.com/dns-query"
dnssecValidation: true
records:
- domain: "www.somedomain.com" # or use "name" as alias
type: "A"
ttl: 3600
ipAddress: "192.168.1.1" # or use "value" as alias for A/AAAA
ptr: true
- domain: "mail.somedomain.com"
type: "MX"
ttl: 3600
exchange: "mail.somedomain.com"
preference: 10
- domain: "somedomain.com"
type: "TXT"
ttl: 3600
text: "v=spf1 ip4:192.168.1.1 -all"
apps:
- name: "Advanced Blocking"
url: "https://download.technitium.com/dns/apps/AdvancedBlockingApp-v8.zip"
config:
enableBlocking: true
blockListUrlUpdateIntervalHours: 24
networkGroupMap:
"0.0.0.0/0": "home"
"::/0": "home"
groups:
- name: home
enableBlocking: true
allowTxtBlockingReport: true
blockAsNxDomain: true
blockingAddresses: [ "0.0.0.0", "::" ]
allowed: []
blocked: []
allowListUrls: []
allowedRegex: []
blockedRegex: []
regexAllowListUrls: []
regexBlockListUrls: []
adblockListUrls: []
blockListUrls:
- "https://raw.githubusercontent.com/xRuffKez/NRD/refs/heads/main/lists/14-day/wildcard/nrd-14day_wildcard.txt"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/pro-onlydomains.txt"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/doh-vpn-proxy-bypass-onlydomains.txt"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/doh-onlydomains.txt"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/nosafesearch-onlydomains.txt"
- "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/dyndns-onlydomains.txt"
- name: "Advanced Forwarding"
url: "https://download.technitium.com/dns/apps/AdvancedForwardingApp-v3.1.zip"
config:
enableForwarding: true
forwarders:
- name: "opendns"
dnssecValidation: true
forwarderProtocol: "Https"
forwarderAddresses:
- "https://doh.opendns.com/dns-query"
- name: "cloudflare"
dnssecValidation: true
forwarderProtocol: "Tls"
forwarderAddresses:
- "tls://1.1.1.1"
- "tls://1.0.0.1"
- name: "quad9"
dnssecValidation: true
forwarderProtocol: "Https"
forwarderAddresses:
- "https://dns.quad9.net/dns-query"
networkGroupMap:
"0.0.0.0/0": "default"
"::/0": "default"
groups:
- name: "default"
enableForwarding: true
forwardings:
- forwarders: ["opendns", "cloudflare", "quad9"]
domains: ["*"]A, AAAA, NS, CNAME, PTR, MX, TXT, SRV, DNAME, DS, SSHFP, TLSA, SVCB, HTTPS, URI, CAA, ANAME, NAPTR, FWD, APP
Records support aliases for convenience: name can be used instead of domain, and value instead of ipAddress for A/AAAA records.
Primary, Secondary, Stub, Forwarder, SecondaryForwarder, Catalog, SecondaryCatalog
Zone transfer protocols: TCP, TLS, QUIC
DNS-level ad/malware blocking with:
- Network group mapping (IPv4/IPv6 CIDR)
- Per-group block/allow lists (URLs and regex)
- Adblock-format list support
- Custom blocking addresses
DNS forwarding with:
- Named forwarders with protocol selection (UDP, TCP, TLS, HTTPS, QUIC)
- SOCKS5/HTTP proxy support per forwarder
- Network group routing
- Domain-specific forwarding rules
- Adguard upstream support
To add support for a new Technitium app:
-
Add a config struct in
pkg/technitium/with json and yaml tags. TheUnmarshalYAMLand struct tags should account for any default value handling the app expects. -
Add a case in the app configuration switch in
pkg/technitium/apps.go:
switch a.Name {
case "Advanced Blocking":
cfg = new(BlockingConfig)
case "New App":
cfg = new(NewAppConfig)
}See examples/docker-compose.yaml for a complete setup with DNS server and configurator containers.
See examples/k8s.yaml for a complete K8s deployment including:
- ConfigMap for DNS configuration
- ExternalSecret for credentials
- Job with init containers for token creation, password change, and configuration
- RBAC (Role, RoleBinding, ServiceAccount) with minimal permissions
Mount configuration files into the container:
config.yaml→/app/config.yamlfor DNS server configurationtoken.yaml→/app/token.yamlif using token file storage
go build -o technitium-configuratordocker build -t technitium-configurator .Multi-arch builds (amd64/arm64) are automated via GitHub Actions on push to main and tag pushes.
End-to-end tests spin up real Technitium DNS servers via Docker and verify the full configurator lifecycle including idempotency and clustering:
go test -tags=e2e ./e2eRequires Docker to be available. The cluster test spins up two DNS servers on a shared Docker network to verify primary init, secondary join, and idempotent re-runs.
When re-running the configurator on existing zones:
- Cannot change: zone type, zone name, zone transfer protocol, TSIG key name
- Can update: records, zone options (catalog, validation, etc.), ACL settings
- Apps are installed if not present
- App configurations are updated if changed
- Cannot uninstall apps through the configurator
- DNS settings are applied first, then cluster init/join, followed by zones and records
primaryURLmust use a hostname (not a raw IP) — Technitium creates aDomainEndPointfrom the URL hostnameprimaryIPshould be provided when the primary hostname can't be resolved by the secondary's DNS (e.g., Docker service names)- After cluster operations, the configurator waits for the server to stabilize and re-authenticates before continuing
- Idempotent — if the cluster is already initialized, the cluster step is skipped
- Only
cluster-stateremains as a standalone command; init/join are now part ofconfigure
- Creates non-expiring tokens
- Idempotent — will not overwrite an existing valid token in file or Kubernetes secret
- Token can be stored in a file (DNS_TOKEN_PATH), a Kubernetes secret (DNS_K8S_SECRET_NAME), or displayed in logs only
- When using Kubernetes secrets: requires cluster access (in-cluster or kubeconfig)
Structured logging with four levels: debug, info, warn, error.
Log level precedence (highest to lowest):
- CLI flag:
--log-level - Environment variable:
DNS_LOG_LEVEL - Config file:
log_level - Default:
info
Warning: Debug logging includes sensitive information such as API tokens and passwords. Use with caution in production environments.