diff --git a/.zuul.yaml b/.zuul.yaml
index fde5a35498..11a08e11a6 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -215,6 +215,22 @@
       - playbooks/templates/clouds/
       - testinfra/test_nodepool.py
 
+- job:
+    name: system-config-run-docker
+    parent: system-config-run
+    description: |
+      Test docker installation and setup
+    nodeset:
+      nodes:
+        - name: bridge.openstack.org
+          label: ubuntu-bionic
+        - name: bionic-docker
+          label: ubuntu-bionic
+    files:
+      - .zuul.yaml
+      - playbooks/roles/install-docker
+      - testinfra/test_docker.py
+
 - job:
     name: system-config-run-dns
     parent: system-config-run
@@ -287,6 +303,7 @@
         - system-config-run-dns
         - system-config-run-eavesdrop
         - system-config-run-nodepool
+        - system-config-run-docker
     gate:
       jobs:
         - tox-linters
@@ -299,3 +316,4 @@
         - system-config-run-dns
         - system-config-run-eavesdrop
         - system-config-run-nodepool
+        - system-config-run-docker
diff --git a/playbooks/base.yaml b/playbooks/base.yaml
index d4165d3fe3..e70b9b2889 100644
--- a/playbooks/base.yaml
+++ b/playbooks/base.yaml
@@ -52,3 +52,8 @@
   name: "Base: configure authoritative nameservers"
   roles:
     - nameserver
+
+- hosts: "docker:!disabled"
+  name: "Base: install and configure docker on docker hosts"
+  roles:
+    - install-docker
diff --git a/playbooks/roles/install-docker/README.rst b/playbooks/roles/install-docker/README.rst
new file mode 100644
index 0000000000..c27188b219
--- /dev/null
+++ b/playbooks/roles/install-docker/README.rst
@@ -0,0 +1,16 @@
+An ansible role to install docker in the OpenStack infra production environment
+
+**Role Variables**
+
+.. zuul:rolevar:: use_upstream_docker
+   :default: True
+
+   By default this role adds repositories to install docker from upstream
+   docker. Set this to False to use the docker that comes with the distro.
+
+.. zuul:rolevar:: docker_update_channel
+   :default: stable
+
+   Which update channel to use for upstream docker. The two choices are
+   ``stable``, which is the default and updates quarterly, and ``edge``
+   which updates monthly.
diff --git a/playbooks/roles/install-docker/defaults/main.yaml b/playbooks/roles/install-docker/defaults/main.yaml
new file mode 100644
index 0000000000..6351680187
--- /dev/null
+++ b/playbooks/roles/install-docker/defaults/main.yaml
@@ -0,0 +1,65 @@
+use_upstream_docker: True
+docker_update_channel: stable
+ubuntu_gpg_key: |
+  -----BEGIN PGP PUBLIC KEY BLOCK-----
+
+  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
+  YT90qFF93M3v01BbxP+EIY2/9tiIPbrd
+  =0YYh
+  -----END PGP PUBLIC KEY BLOCK-----
diff --git a/playbooks/roles/install-docker/tasks/distro.yaml b/playbooks/roles/install-docker/tasks/distro.yaml
new file mode 100644
index 0000000000..99fd589ac7
--- /dev/null
+++ b/playbooks/roles/install-docker/tasks/distro.yaml
@@ -0,0 +1,5 @@
+- name: Install docker
+  become: yes
+  package:
+    name: docker.io
+    state: present
diff --git a/playbooks/roles/install-docker/tasks/main.yaml b/playbooks/roles/install-docker/tasks/main.yaml
new file mode 100644
index 0000000000..296a1e391a
--- /dev/null
+++ b/playbooks/roles/install-docker/tasks/main.yaml
@@ -0,0 +1,25 @@
+- name: Create docker directory
+  become: yes
+  file:
+    state: directory
+    path: /etc/docker
+
+- name: Install docker configuration
+  become: yes
+  template:
+    dest: /etc/docker/daemon.json
+    group: root
+    mode: 0644
+    owner: root
+    src: daemon.json.j2
+
+- name: Install docker-ce from upstream
+  include: upstream.yaml
+  when: use_upstream_docker
+
+- name: Install docker-engine from distro
+  include: distro.yaml
+  when: not use_upstream_docker
+
+- name: reset ssh connection to pick up docker group
+  meta: reset_connection
diff --git a/playbooks/roles/install-docker/tasks/upstream.yaml b/playbooks/roles/install-docker/tasks/upstream.yaml
new file mode 100644
index 0000000000..ca5463c3a1
--- /dev/null
+++ b/playbooks/roles/install-docker/tasks/upstream.yaml
@@ -0,0 +1,32 @@
+- name: Install pre-reqs
+  package:
+    name: "{{ item }}"
+    state: present
+  with_items:
+    - apt-transport-https
+    - ca-certificates
+    - curl
+    - software-properties-common
+  become: yes
+
+- name: Add docker GPG key
+  become: yes
+  apt_key:
+    data: "{{ ubuntu_gpg_key }}"
+
+# TODO(mordred) We should add a proxy cache mirror for this
+- name: Add docker apt repo
+  become: yes
+  template:
+    dest: /etc/apt/sources.list.d/docker.list
+    group: root
+    mode: 0644
+    owner: root
+    src: sources.list.j2
+
+- name: Install docker
+  become: yes
+  apt:
+    name: docker-ce
+    state: present
+    update_cache: yes
diff --git a/playbooks/roles/install-docker/templates/daemon.json.j2 b/playbooks/roles/install-docker/templates/daemon.json.j2
new file mode 100644
index 0000000000..2c63c08510
--- /dev/null
+++ b/playbooks/roles/install-docker/templates/daemon.json.j2
@@ -0,0 +1,2 @@
+{
+}
diff --git a/playbooks/roles/install-docker/templates/sources.list.j2 b/playbooks/roles/install-docker/templates/sources.list.j2
new file mode 100644
index 0000000000..7cf0b6843e
--- /dev/null
+++ b/playbooks/roles/install-docker/templates/sources.list.j2
@@ -0,0 +1 @@
+deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} {{ docker_update_channel }}
diff --git a/playbooks/zuul/templates/gate-groups.yaml.j2 b/playbooks/zuul/templates/gate-groups.yaml.j2
index 990453e264..a8c8bfeb05 100644
--- a/playbooks/zuul/templates/gate-groups.yaml.j2
+++ b/playbooks/zuul/templates/gate-groups.yaml.j2
@@ -7,3 +7,6 @@ groups:
     - xenial
     - centos7
     # note: bionic currently isn't puppeted
+
+  docker:
+    - bionic-docker
diff --git a/testinfra/test_base.py b/testinfra/test_base.py
index 05032aa427..e55e2969d3 100644
--- a/testinfra/test_base.py
+++ b/testinfra/test_base.py
@@ -62,7 +62,7 @@ def test_iptables(host):
     rules = host.iptables.rules()
     rules = [x.strip() for x in rules]
 
-    start = [
+    needed_rules = [
         '-P INPUT ACCEPT',
         '-P FORWARD DROP',
         '-P OUTPUT ACCEPT',
@@ -72,11 +72,10 @@ def test_iptables(host):
         '-A openstack-INPUT -p icmp -m icmp --icmp-type any -j ACCEPT',
         '-A openstack-INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT',
         '-A openstack-INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT',
+        '-A openstack-INPUT -j REJECT --reject-with icmp-host-prohibited'
     ]
-    assert rules[:len(start)] == start
-
-    reject = '-A openstack-INPUT -j REJECT --reject-with icmp-host-prohibited'
-    assert reject in rules
+    for rule in needed_rules:
+        assert rule in rules
 
     # Make sure that the zuul console stream rule is still present
     zuul = ('-A openstack-INPUT -p tcp -m state --state NEW'
diff --git a/testinfra/test_docker.py b/testinfra/test_docker.py
new file mode 100644
index 0000000000..1b71579e67
--- /dev/null
+++ b/testinfra/test_docker.py
@@ -0,0 +1,26 @@
+# 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 = ['bionic-docker']
+
+
+def test_docker_config(host):
+    daemon_json = host.file('/etc/docker/daemon.json')
+    assert daemon_json.exists
+
+
+def test_docker_service(host):
+    docker = host.service('docker')
+    assert docker.is_running
diff --git a/testinfra/test_firewall.py b/testinfra/test_firewall.py
new file mode 100644
index 0000000000..b42293640a
--- /dev/null
+++ b/testinfra/test_firewall.py
@@ -0,0 +1,69 @@
+# 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.
+
+import socket
+
+# TODO(ianw): docker fiddles the firewall rules; update these to
+# handle docker too.
+testinfra_hosts = ['all:!bionic-docker']
+
+
+def get_ips(value, family=None):
+    ret = set()
+    try:
+        addr_info = socket.getaddrinfo(value, None, family)
+    except socket.gaierror:
+        return ret
+    for addr in addr_info:
+        ret.add(addr[4][0])
+    return ret
+
+
+def test_iptables(host):
+    rules = host.iptables.rules()
+    rules = [x.strip() for x in rules]
+
+    start = [
+        '-P INPUT ACCEPT',
+        '-P FORWARD DROP',
+        '-P OUTPUT ACCEPT',
+        '-N openstack-INPUT',
+        '-A INPUT -j openstack-INPUT',
+        '-A openstack-INPUT -i lo -j ACCEPT',
+        '-A openstack-INPUT -p icmp -m icmp --icmp-type any -j ACCEPT',
+        '-A openstack-INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT',
+        '-A openstack-INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT',
+    ]
+    assert rules[:len(start)] == start
+
+    reject = '-A openstack-INPUT -j REJECT --reject-with icmp-host-prohibited'
+    assert reject in rules
+
+    # Make sure that the zuul console stream rule is still present
+    zuul = ('-A openstack-INPUT -p tcp -m state --state NEW'
+            ' -m tcp --dport 19885 -j ACCEPT')
+    assert zuul in rules
+
+    # Ensure all IPv4+6 addresses for cacti are allowed
+    for ip in get_ips('cacti.openstack.org', socket.AF_INET):
+        snmp = ('-A openstack-INPUT -s %s/32 -p udp -m udp'
+                ' --dport 161 -j ACCEPT' % ip)
+        assert snmp in rules
+
+    # TODO(ianw) add ip6tables support to testinfra iptables module
+    ip6rules = host.check_output('ip6tables -S')
+    for ip in get_ips('cacti.openstack.org', socket.AF_INET6):
+        snmp = ('-A openstack-INPUT -s %s/128 -p udp -m udp'
+                ' --dport 161 -j ACCEPT' % ip)
+        assert snmp in ip6rules