diff --git a/docs/geomirrors.md b/docs/geomirrors.md
index 465f97f85a6bb08a21b6768ed0ee79b1baf58238..9b2968e5cf17b7ed535a94fa2c661c018be24297 100644
--- a/docs/geomirrors.md
+++ b/docs/geomirrors.md
@@ -20,7 +20,7 @@ The continent mirrors america, asia and europe contain the archive mirrors as we
 - Host with Arch Linux installed
 - root access provided
 - Enough storage to host repos / debugrepos (at least)
-- Bandwidth (depends on location)   
+- Bandwidth (depends on location)
 
 ## Adding a new mirror box
 - Add new entries in `hosts` file under `mirrors` and `geo_mirrors` sections
@@ -38,7 +38,7 @@ The continent mirrors america, asia and europe contain the archive mirrors as we
 | ----------- | ----------- | ----------- | ----------- |  ----------- |
 | install_arch | All | Install Arch | | Optional if you can |
 | mirrors.yml | All | Setup mirror | `<fqdn>` | |
-| redirect.archlinux.org.yml | acme_dns_challenge | Make TXT records | | |
+| redirect.archlinux.org.yml | dyn_dns | Make TXT records | | |
 | gemini.archlinux.org.yml | dbscripts | Allow debug repo syncing | | |
 | mirrors.yml | geo_dns | Add new domain to DNS | All other mirrors from geo.mirror | |
 | monitoring.archlinux.org.yml | wireguard,prometheus | Allow loki and prometheus to fetch data | | |
diff --git a/group_vars/all/dyn_dns.yml b/group_vars/all/dyn_dns.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6d6bce7bc079635539f76010c24612c9e27e6277
--- /dev/null
+++ b/group_vars/all/dyn_dns.yml
@@ -0,0 +1,8 @@
+dyn_dns_server: "{{ hostvars['redirect.archlinux.org']['ipv4_address'] }}"
+dyn_dns_zones:
+  _acme-challenge.geo.mirror.pkgbuild.com: &acme_challenge
+    key: certbot
+    allowed_ipv4: "{{ groups['geo_mirrors'] | map('extract', hostvars, ['ipv4_address']) }}"
+    allowed_ipv6: "{{ groups['geo_mirrors'] | map('extract', hostvars, ['ipv6_address']) }}"
+    valid_qtypes: [TXT]
+  _acme-challenge.riscv.mirror.pkgbuild.com: *acme_challenge
diff --git a/group_vars/all/vault_dyn_dns_keys.yml b/group_vars/all/vault_dyn_dns_keys.yml
new file mode 100644
index 0000000000000000000000000000000000000000..272d3993442dbdbebea51b0f8ff670e5eb8325ad
--- /dev/null
+++ b/group_vars/all/vault_dyn_dns_keys.yml
@@ -0,0 +1,13 @@
+$ANSIBLE_VAULT;1.1;AES256
+61373835393530366133386434373162656332363939656235646235663333633532336435353266
+3364616435323230656233666633353535303436363433610a376133633938663634323932643764
+36656433366566623864636462383861636538363737343861316330306561373965626366363032
+6366373462303839660a653335623261306630623139643630323330633665393030333830653930
+37653166613264643537383734336163313537313334363635653062653832333638356361313461
+62353166393332326534356661653464333266383234396536383633323834333566633861643363
+66316162356566343964623237356264633564646634653834326363386235333361656332386265
+39333463343365393962663637666333376236366638306361316435306537643031346162346464
+33313466353666353136386463353831353365643333613066326136343234343636343833346465
+64343962303766303436613538616165623837383837303230623135623562303664333764323834
+62313864653234653138336134303638666234376631663361396662653863643433313864303330
+63663034353461346562
diff --git a/group_vars/geo_mirrors/vault_certbot.yml b/group_vars/geo_mirrors/vault_certbot.yml
deleted file mode 120000
index 760d5f94f435cbe9cc884917061dc28dbcb1445b..0000000000000000000000000000000000000000
--- a/group_vars/geo_mirrors/vault_certbot.yml
+++ /dev/null
@@ -1 +0,0 @@
-../../host_vars/redirect.archlinux.org/vault_certbot.yml
\ No newline at end of file
diff --git a/host_vars/redirect.archlinux.org/vault_certbot.yml b/host_vars/redirect.archlinux.org/vault_certbot.yml
deleted file mode 100644
index 679df61a0ee824fad00dcac1428cbc729f24813d..0000000000000000000000000000000000000000
--- a/host_vars/redirect.archlinux.org/vault_certbot.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-$ANSIBLE_VAULT;1.1;AES256
-36396331303031376233613930366238363633633464636336663163363234623939623731616536
-6262353930613038346636343364663532343539343661620a313262666231333164653639653531
-64626638366531653865616130653235323933376235306130663034636633343764383264373632
-6335643131366364620a353835353663643533396161626462343566376264633331336365373936
-31393234333539656230633531626438643466656438643530363466303337356263333031396362
-65656461313261303461643062353634303266316163346132656135323639363833306335343831
-62353437643537333430343263626630323761356530386466633964336430373636623937326138
-62336564326462366661323665663032363939353138366132636564613364386266643762326565
-33386235393830336563363836333732656637363661666661656434326231323662383962643761
-66303761323336383838303166313766336338656433383834663932356431613638643563353865
-39346432366437303334343339383835646135326435656637646463303332343734643138653236
-33643739373839656138376339626134663332613438643036656430306338393436396465616337
-35336463383032346632626536383433633436653037613336313837386336306362323766356465
-30336233356631643362316539326135363961393435303535376136633762373061633965353564
-31393161646132323833653936346464646532353830643362366433653934326563646166303862
-62666364326563353439663636383437613134333836643134646135326435646234653762333438
-61306361393763356633303736333535656331636461333237633134626231633635
diff --git a/playbooks/redirect.archlinux.org.yml b/playbooks/redirect.archlinux.org.yml
index 5bbcd4da8e9d81fdec96144f1b3c8296649e2297..3bae351d4f54f8e574eca92ac7e22a6698158d7c 100644
--- a/playbooks/redirect.archlinux.org.yml
+++ b/playbooks/redirect.archlinux.org.yml
@@ -14,4 +14,4 @@
     - { role: promtail }
     - { role: hardening }
     - { role: ping }
-    - { role: acme_dns_challenge }
+    - { role: dyn_dns }
diff --git a/roles/acme_dns_challenge/templates/dnsupdate-policy.lua.j2 b/roles/acme_dns_challenge/templates/dnsupdate-policy.lua.j2
deleted file mode 100644
index 50fc8a2223780aedcdd8a6618e25d9afd50358f2..0000000000000000000000000000000000000000
--- a/roles/acme_dns_challenge/templates/dnsupdate-policy.lua.j2
+++ /dev/null
@@ -1,42 +0,0 @@
-#jinja2: lstrip_blocks: True
--- Based on https://github.com/PowerDNS/pdns/wiki/Lua-Examples-(Authoritative)#updatepolicy-access-control-for-rfc2136-dynamic-updates
-function updatepolicy(input)
-  valid_rrnames = {
-    {% for domain in geo_domains %}
-    ["_acme-challenge.{{ domain }}."]=true,
-    {% endfor %}
-  }
-
-  -- only allow updates from our servers
-  mynetworks = newNMG()
-  mynetworks:addMasks({
-{% for host in groups['geo_mirrors'] | sort %}
-    '{{ hostvars[host]['ipv4_address'] }}/32',
-    '{{ hostvars[host]['ipv6_address'] }}/128',
-{% endfor %}
-  })
-
-  -- ignore non-authorized networks
-  if not mynetworks:match(input:getRemote())
-  then
-    pdnslog("updatepolicy: network check failed from " .. input:getRemote():toString(), pdns.loglevels.Info)
-    return false
-  end
-
-  -- ignore non-TSIG requests
-  if input:getTsigName():countLabels() == 0
-  then
-    pdnslog("updatepolicy: missing TSIG", pdns.loglevels.Info)
-    return false
-  end
-
-  -- only accept TXT record updates for _acme_challenge
-  if input:getQType() == pdns.TXT and valid_rrnames[input:getQName():toString()]
-  then
-    pdnslog("updatepolicy: query checks successful", pdns.loglevels.Info)
-    return true
-  end
-
-  pdnslog("updatepolicy: query checks failed", pdns.loglevels.Info)
-  return false
-end
diff --git a/roles/certbot/templates/rfc2136.ini.j2 b/roles/certbot/templates/rfc2136.ini.j2
index 32bf6978e8870790343a5e05028a387241eec90e..3207643de31f069d2059a4ae62a83c26a6861bee 100644
--- a/roles/certbot/templates/rfc2136.ini.j2
+++ b/roles/certbot/templates/rfc2136.ini.j2
@@ -1,4 +1,4 @@
-dns_rfc2136_server = {{ certbot_rfc2136_server }}
-dns_rfc2136_name = {{ certbot_rfc2136_key }}
-dns_rfc2136_secret = {{ certbot_rfc2136_secret }}
-dns_rfc2136_algorithm = {{ certbot_rfc2136_algorithm }}
+dns_rfc2136_server = {{ dyn_dns_server }}
+dns_rfc2136_name = certbot
+dns_rfc2136_secret = {{ dyn_dns_keys['certbot'].secret }}
+dns_rfc2136_algorithm = {{ dyn_dns_keys['certbot'].algorithm | upper }}
diff --git a/roles/acme_dns_challenge/handlers/main.yml b/roles/dyn_dns/handlers/main.yml
similarity index 100%
rename from roles/acme_dns_challenge/handlers/main.yml
rename to roles/dyn_dns/handlers/main.yml
diff --git a/roles/acme_dns_challenge/tasks/main.yml b/roles/dyn_dns/tasks/main.yml
similarity index 66%
rename from roles/acme_dns_challenge/tasks/main.yml
rename to roles/dyn_dns/tasks/main.yml
index 2a7873f62c3b9975b9ef50a1fa4f4e3f5e97f1a7..f2d24c3b113d40cc8743b361532ed60c55cd579e 100644
--- a/roles/acme_dns_challenge/tasks/main.yml
+++ b/roles/dyn_dns/tasks/main.yml
@@ -8,27 +8,30 @@
     - {src: dnsupdate-policy.lua.j2, dest: dnsupdate-policy.lua}
   notify: Restart powerdns
 
-- name: Create directory for sqlite3 dbs
+- name: Create directory for sqlite3 database
   file: path=/var/lib/powerdns state=directory owner=powerdns group=powerdns mode=0755
 
-- name: Initialize sqlite3 database for _acme-challenge zones
+- name: Initialize sqlite3 database
   command: sqlite3 -init /usr/share/doc/powerdns/schema.sqlite3.sql /var/lib/powerdns/pdns.sqlite3 ""
   become: true
   become_user: powerdns
   args:
     creates: /var/lib/powerdns/pdns.sqlite3
 
-- name: Create _acme-challenge zones
+- name: Create zones
   shell: |
-    pdnsutil create-zone _acme-challenge.{{ item }} {{ inventory_hostname }}
-    pdnsutil replace-rrset _acme-challenge.{{ item }} @ SOA "{{ inventory_hostname }}. root.archlinux.org. 0 10800 3600 604800 3600"
-  loop: "{{ geo_domains }}"
-  become: true
-  become_user: powerdns
+    pdnsutil create-zone {{ item.zone }} {{ inventory_hostname }}
+    pdnsutil replace-rrset {{ item.zone }} @ SOA "{{ inventory_hostname }}. root.archlinux.org. 0 10800 3600 604800 3600"
+  loop: "{{ dyn_dns_zones | dict2items(key_name='zone') }}"
+  loop_control:
+    label: "{{ item.zone }}"
   changed_when: false
 
-- name: Import TSIG key (for certbot)
-  command: pdnsutil import-tsig-key {{ certbot_rfc2136_key }} {{ certbot_rfc2136_algorithm }} {{ certbot_rfc2136_secret }}
+- name: Import TSIG keys
+  command: pdnsutil import-tsig-key {{ item.key }} {{ item.value.algorithm }} {{ item.value.secret }}
+  loop: "{{ dyn_dns_keys | dict2items }}"
+  loop_control:
+    label: "{{ item.key }}"
   changed_when: false
 
 - name: Open powerdns ipv4 port for monitoring.archlinux.org
diff --git a/roles/dyn_dns/templates/dnsupdate-policy.lua.j2 b/roles/dyn_dns/templates/dnsupdate-policy.lua.j2
new file mode 100644
index 0000000000000000000000000000000000000000..833a9886d72903c064fbfd76ff196cf4efd25bec
--- /dev/null
+++ b/roles/dyn_dns/templates/dnsupdate-policy.lua.j2
@@ -0,0 +1,86 @@
+#jinja2: lstrip_blocks: True
+-- Based on https://github.com/PowerDNS/pdns/wiki/Lua-Examples-(Authoritative)#updatepolicy-access-control-for-rfc2136-dynamic-updates
+function updatepolicy(input)
+  local zones = {
+{% for zone, prop in dyn_dns_zones.items() %}
+    ["{{ zone }}."] = {
+      ["key"] = "{{ prop.key }}.",
+      ["allowed_networks"] = {
+        {% for ipv4 in prop.allowed_ipv4 %}
+        '{{ ipv4 }}{{ '' if '/' in ipv4 else '/32' }}',
+        {% endfor %}
+        {% for ipv6 in prop.allowed_ipv6 %}
+        '{{ ipv6 }}{{ '' if '/' in ipv6 else '/128' }}',
+        {% endfor %}
+      },
+      ["valid_qtypes"] = {
+        {% for qtype in prop.valid_qtypes %}
+        [pdns.{{ qtype }}] = true,
+        {% endfor %}
+      },
+      ["subdomains"] = "{{ prop.subdomains | default('no') }}",
+    },
+{% endfor %}
+  }
+
+  local zone_name = input:getZoneName():toString()
+  local zone = zones[zone_name]
+
+  -- reject unknown zones
+  if not zone
+  then
+    pdnslog("updatepolicy: unknown zone " .. zone_name, pdns.loglevels.Info)
+    return false
+  end
+
+  local allowed_networks = newNMG(zone["allowed_networks"])
+
+  -- reject unauthorized networks
+  if not allowed_networks:match(input:getRemote())
+  then
+    pdnslog("updatepolicy: network check failed from " .. input:getRemote():toString(), pdns.loglevels.Info)
+    return false
+  end
+
+  input_qname = input:getQName():toString()
+
+  -- reject subdomain records when subdomains == "no"
+  if zone["subdomains"] == "no" and input_qname ~= zone_name
+  then
+    pdnslog("updatepolicy: subdomain records not allowed in zone " .. zone_name, pdns.loglevels.Info)
+    return false
+  end
+
+  -- reject apex records when subdomains == "only"
+  if zone["subdomains"] == "only" and input_qname == zone_name
+  then
+    pdnslog("updatepolicy: apex records not allowed in zone " .. zone_name, pdns.loglevels.Info)
+    return false
+  end
+
+  -- reject non-TSIG requests
+  if input:getTsigName():countLabels() == 0
+  then
+    pdnslog("updatepolicy: missing TSIG", pdns.loglevels.Info)
+    return false
+  end
+
+  input_tsig_name = input:getTsigName():toString()
+
+  -- reject unauthorized TSIG key names
+  if zone["key"] ~= input_tsig_name
+  then
+    pdnslog("updatepolicy: wrong TSIG " .. input_tsig_name .. " for zone " .. zone_name, pdns.loglevels.Info)
+    return false
+  end
+
+  -- reject disallowed record types
+  if not zone["valid_qtypes"][input:getQType()]
+  then
+    pdnslog("updatepolicy: disallowed record type " .. input:getQType(), pdns.loglevels.Info)
+    return false
+  end
+
+  pdnslog("updatepolicy: query checks successful", pdns.loglevels.Info)
+  return true
+end
diff --git a/roles/acme_dns_challenge/templates/pdns.conf.j2 b/roles/dyn_dns/templates/pdns.conf.j2
similarity index 100%
rename from roles/acme_dns_challenge/templates/pdns.conf.j2
rename to roles/dyn_dns/templates/pdns.conf.j2