diff --git a/.zuul.yaml b/.zuul.yaml
index bc2d797188..f5310c422b 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -195,6 +195,26 @@
       - playbooks/templates/clouds/
       - testinfra/test_nodepool.py
 
+- job:
+    name: system-config-run-dns
+    parent: system-config-run
+    description: |
+      Run the playbook for dns.
+    nodeset:
+      nodes:
+        - name: bridge.openstack.org
+          label: ubuntu-bionic
+        - name: adns1.opendev.org
+          label: ubuntu-bionic
+    files:
+      - .zuul.yaml
+      - playbooks/group_vars/adns.yaml
+      - playbooks/group_vars/dns.yaml
+      - playbooks/host_vars/adns1.opendev.org.yaml
+      - playbooks/zuul/templates/group_vars/adns.yaml.j2
+      - playbooks/roles/master-nameserver/
+      - testinfra/test_adns.py
+
 - job:
     name: infra-prod-playbook
     description: |
@@ -237,6 +257,7 @@
         - puppet-beaker-rspec-puppet-4-infra-system-config
         - puppet-beaker-rspec-puppet-4-centos-7-infra-system-config
         - system-config-run-base
+        - system-config-run-dns
         - system-config-run-eavesdrop
         - system-config-run-nodepool
     gate:
@@ -248,5 +269,6 @@
         - puppet-beaker-rspec-puppet-4-infra-system-config
         - puppet-beaker-rspec-puppet-4-centos-7-infra-system-config
         - system-config-run-base
+        - system-config-run-dns
         - system-config-run-eavesdrop
         - system-config-run-nodepool
diff --git a/inventory/groups.yaml b/inventory/groups.yaml
index 5bbdf4804c..6f68b636ab 100644
--- a/inventory/groups.yaml
+++ b/inventory/groups.yaml
@@ -27,6 +27,9 @@ groups:
     - wiki-dev01.openstack.org
     - wiki-upgrade-test.openstack.org
     - wiki.openstack.org
+  dns:
+    - adns*.opendev.org
+    - ns*.opendev.org
   eavesdrop: eavesdrop[0-9]*.openstack.org
   elasticsearch: elasticsearch[0-9]*.openstack.org
   ethercalc: ethercalc*
@@ -99,8 +102,7 @@ groups:
   nodepool-launcher:
     - nl[0-9]*.openstack.org
   ns:
-    - ns1.openstack.org
-    - ns2.openstack.org
+    - ns[0-9]*.*
   paste:
     - paste01.openstack.org
   pbx:
diff --git a/playbooks/base.yaml b/playbooks/base.yaml
index 57a1abaa88..10f416de21 100644
--- a/playbooks/base.yaml
+++ b/playbooks/base.yaml
@@ -41,3 +41,8 @@
   roles:
     - puppet-install
     - disable-puppet-agent
+
+- hosts: "adns1.opendev.org:!disabled"
+  name: "Base: configure adns1.opendev.org"
+  roles:
+    - master-nameserver
diff --git a/playbooks/group_vars/dns.yaml b/playbooks/group_vars/dns.yaml
new file mode 100644
index 0000000000..8d8cccbe48
--- /dev/null
+++ b/playbooks/group_vars/dns.yaml
@@ -0,0 +1,15 @@
+dns_repos:
+  - name: zone-opendev.org
+    url: https://git.openstack.org/openstack-infra/zone-opendev.org
+  - name: zone-zuul-ci.org
+    url: https://git.openstack.org/openstack-infra/zone-zuul-ci.org
+dns_zones:
+  - name: opendev.org
+    source: zone-opendev.org/zones/opendev.org/
+  - name: zuul-ci.org
+    source: zone-zuul-ci.org/zones/zuul-ci.org/
+  - name: zuulci.org
+    source: zone-zuul-ci.org/zones/zuulci.org/
+dns_notify:
+  - 104.239.140.165
+  - 162.253.55.16
diff --git a/playbooks/roles/master-nameserver/README.rst b/playbooks/roles/master-nameserver/README.rst
new file mode 100644
index 0000000000..4003243af3
--- /dev/null
+++ b/playbooks/roles/master-nameserver/README.rst
@@ -0,0 +1,77 @@
+Configure a hidden master nameserver
+
+This role installs and configures bind9 to be a hidden master
+nameserver.
+
+**Role Variables**
+
+.. zuul:rolevar:: tsig_key
+   :type: dict
+
+   The TSIG key used to control named.
+
+   .. zuul:rolevar:: algorithm
+
+      The algorithm used by the key.
+
+   .. zuul:rolevar:: secret
+
+      The secret portion of the key.
+
+.. zuul:rolevar:: dnssec_keys
+   :type: dict
+
+   This is a dictionary of DNSSEC keys.  Each entry is a dnssec key,
+   where the dictionary key is the dnssec key id and the value is the
+   a dictionary with the following contents:
+
+   .. zuul:rolevar:: zone
+
+      The name of the zone for this key.
+
+   .. zuul:rolevar:: public
+
+      The public portion of this key.
+
+   .. zuul:rolevar:: private
+
+      The private portion of this key.
+
+.. zuul:rolevar:: dns_repos
+   :type: list
+
+   A list of zone file repos to check out on the server.  Each item in
+   the list is a dictionary with the following keys:
+
+   .. zuul:rolevar:: name
+
+      The name of the repo.
+
+   .. zuul:rolevar:: url
+
+      The URL of the git repository.
+
+.. zuul:rolevar:: dns_zones
+   :type: list
+
+   A list of zones that should be served by named.  Each item in the
+   list is a dictionary with the following keys:
+
+   .. zuul:rolevar:: name
+
+      The name of the zone.
+
+   .. zuul:rolevar:: source
+
+      The repo name and path of the directory containing the zone
+      file.  For example if a repo was provided to
+      :zuul:rolevar:`master-nameserver.dns_repos.name` with the name
+      ``example.com``, and within that repo, the ``zone.db`` file was
+      located at ``zones/example_com/zone.db``, then the value here
+      should be ``example.com/zones/example_com``.
+
+.. zuul:rolevar:: dns_notify
+   :type: list
+
+   A list of IP addresses of nameservers which named should notify on
+   updates.
diff --git a/playbooks/roles/master-nameserver/handlers/main.yaml b/playbooks/roles/master-nameserver/handlers/main.yaml
new file mode 100644
index 0000000000..55a13392e7
--- /dev/null
+++ b/playbooks/roles/master-nameserver/handlers/main.yaml
@@ -0,0 +1,2 @@
+- name: Reload named
+  command: "rndc reload"
diff --git a/playbooks/roles/master-nameserver/tasks/main.yaml b/playbooks/roles/master-nameserver/tasks/main.yaml
new file mode 100644
index 0000000000..b6f4fa93b4
--- /dev/null
+++ b/playbooks/roles/master-nameserver/tasks/main.yaml
@@ -0,0 +1,68 @@
+- name: Install packages
+  package:
+    name:
+      - bind9
+      - git
+      - rsync
+    state: present
+- name: Ensure base zone directory exists
+  file:
+    path: /var/lib/bind/zones
+    state: directory
+- name: Clone zone repos
+  git:
+    repo: "{{ item.url }}"
+    dest: "/opt/source/{{ item.name }}"
+  loop: "{{ dns_repos }}"
+- name: Synchronize zone repos to zone directories
+  delegate_to: "{{ inventory_hostname }}"
+  synchronize:
+    src: "/opt/source/{{ item.source }}"
+    dest: "/var/lib/bind/zones/{{ item.name }}"
+  loop: "{{ dns_zones }}"
+  notify: Reload named
+- name: Install tsig key
+  no_log: true
+  template:
+    src: templates/bind.key.j2
+    dest: "/etc/bind/tsig.key"
+    owner: root
+    group: bind
+    mode: 0440
+  vars:
+    key: "{{ tsig_key }}"
+    name: tsig
+- name: Ensure base dnssec key directory exists
+  file:
+    path: /etc/bind/keys
+    state: directory
+# The key directories must exist for every zone, regardless of whether
+# there are any keys in them.
+- name: Ensure zone dnssec key directories exist
+  loop: "{{ dns_zones }}"
+  file:
+    path: "/etc/bind/keys/{{ item.name }}"
+    state: directory
+- name: Install dnssec public keys
+  loop: "{{ dnssec_keys | dict2items }}"
+  copy:
+    dest: "/etc/bind/keys/{{ item.value.zone }}/{{ item.value.zone }}.+008+{{ item.key }}.key"
+    content: "{{ item.value.public }}"
+- name: Install dnssec private keys
+  no_log: true
+  loop: "{{ dnssec_keys | dict2items }}"
+  copy:
+    dest: "/etc/bind/keys/{{ item.value.zone }}/{{ item.value.zone }}.+008+{{ item.key }}.private"
+    content: "{{ item.value.private }}"
+- name: Install bind config
+  template:
+    src: templates/named.conf.j2
+    dest: /etc/bind/named.conf
+    owner: root
+    group: bind
+    mode: 0444
+  notify: Reload named
+- name: Enable named
+  service:
+    name: bind9
+    enabled: true
diff --git a/playbooks/roles/master-nameserver/templates/bind.key.j2 b/playbooks/roles/master-nameserver/templates/bind.key.j2
new file mode 100644
index 0000000000..5f46db2657
--- /dev/null
+++ b/playbooks/roles/master-nameserver/templates/bind.key.j2
@@ -0,0 +1,4 @@
+key "{{ name }}" {
+    algorithm {{ key.algorithm }};
+    secret "{{ key.secret }}";
+};
diff --git a/playbooks/roles/master-nameserver/templates/named.conf.j2 b/playbooks/roles/master-nameserver/templates/named.conf.j2
new file mode 100644
index 0000000000..66ebea2ac7
--- /dev/null
+++ b/playbooks/roles/master-nameserver/templates/named.conf.j2
@@ -0,0 +1,49 @@
+include "/etc/bind/rndc.key";
+include "/etc/bind/tsig.key";
+
+controls  {
+  inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
+};
+
+options  {
+  directory "/var/cache/bind";
+
+  recursion yes;
+  allow-query { any; };
+  dnssec-enable yes;
+  dnssec-validation yes;
+
+  empty-zones-enable yes;
+
+  notify yes;
+{% if 'address' in ansible_facts.default_ipv6
+       and 'scope' in ansible_facts.default_ipv6
+       and ansible_facts.default_ipv6.scope == 'global' %}
+  listen-on-v6 { {{ ansible_facts.default_ipv6.address }}; };
+{% endif %}
+
+  allow-recursion { localnets; localhost; };
+
+  allow-transfer { key tsig; };
+  also-notify {
+  {% for host in dns_notify %}
+    {{ host }};
+  {% endfor %}
+  };
+
+{% if 'address' in ansible_facts.default_ipv4 %}
+  listen-on { {{ ansible_facts.default_ipv4.address }}; };
+{% endif %}
+};
+
+include "/etc/bind/zones.rfc1918";
+
+{% for zone in dns_zones %}
+zone {{ zone.name }} {
+  type master;
+  file "/var/lib/bind/zones/{{ zone.name }}/zone.db";
+  key-directory "/etc/bind/keys/{{ zone.name }}";
+  auto-dnssec maintain;
+  inline-signing yes;
+};
+{% endfor %}
diff --git a/playbooks/zuul/run-base.yaml b/playbooks/zuul/run-base.yaml
index eb8c51bc8e..ecb48e058e 100644
--- a/playbooks/zuul/run-base.yaml
+++ b/playbooks/zuul/run-base.yaml
@@ -58,6 +58,7 @@
         dest: "/etc/ansible/hosts/{{ item }}"
       loop:
         - group_vars/all.yaml
+        - group_vars/adns.yaml
         - group_vars/nodepool.yaml
         - host_vars/bridge.openstack.org.yaml
     - name: Display group membership
diff --git a/playbooks/zuul/run-production-playbook.yaml b/playbooks/zuul/run-production-playbook.yaml
index ac1004461d..d641b4f990 100644
--- a/playbooks/zuul/run-production-playbook.yaml
+++ b/playbooks/zuul/run-production-playbook.yaml
@@ -9,4 +9,3 @@
   tasks:
     - name: Run specified playbook on bridge.o.o
       command: ansible-playbook -f {{ ansible_forks }} /opt/system-config/playbooks/{{ playbook_name }}
-
diff --git a/playbooks/zuul/templates/group_vars/adns.yaml.j2 b/playbooks/zuul/templates/group_vars/adns.yaml.j2
new file mode 100644
index 0000000000..ec16d77a03
--- /dev/null
+++ b/playbooks/zuul/templates/group_vars/adns.yaml.j2
@@ -0,0 +1,12 @@
+tsig_key:
+  algorithm: hmac-md5
+  secret: 9zO/4WnUinnLHISPgDI5Aw==
+dnssec_keys:
+  54873:
+    zone: zuul-ci.org
+    public: public_key
+    private: private_key
+  04765:
+    zone: zuul-ci.org
+    public: public_key
+    private: private_key
diff --git a/testinfra/test_adns.py b/testinfra/test_adns.py
new file mode 100644
index 0000000000..a5418a7d84
--- /dev/null
+++ b/testinfra/test_adns.py
@@ -0,0 +1,21 @@
+# Copyright 2018 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+testinfra_hosts = ['adns1.opendev.org']
+
+
+def test_bind(host):
+    named = host.service('bind9')
+    assert named.is_running