diff --git a/dev/functions b/dev/functions
index 2d8fbeea9b5dc00b039312f079835c2412fe0837..459ffd4849a75ec5b318245f57303a0deca13853 100644
--- a/dev/functions
+++ b/dev/functions
@@ -35,9 +35,19 @@ function config_defaults {
     export KAYOBE_SEED_CONTAINER_IMAGE_BUILD=${KAYOBE_SEED_CONTAINER_IMAGE_BUILD:-0}
 
     # Whether to build container images for the overcloud services. If 0, they
-    # will be pulled.
+    # will be pulled if $KAYOBE_OVERCLOUD_CONTAINER_IMAGE_PULL is 1.
     export KAYOBE_OVERCLOUD_CONTAINER_IMAGE_BUILD=${KAYOBE_OVERCLOUD_CONTAINER_IMAGE_BUILD:-0}
 
+    # Whether to pull container images for the overcloud services if
+    # $KAYOBE_OVERCLOUD_CONTAINER_IMAGE_BUILD is 0.
+    export KAYOBE_OVERCLOUD_CONTAINER_IMAGE_PULL=${KAYOBE_OVERCLOUD_CONTAINER_IMAGE_PULL:-1}
+
+    # Whether to deploy overcloud services.
+    export KAYOBE_OVERCLOUD_SERVICE_DEPLOY=${KAYOBE_OVERCLOUD_SERVICE_DEPLOY:-1}
+
+    # Whether to perform overcloud post configuration.
+    export KAYOBE_OVERCLOUD_POST_CONFIGURE=${KAYOBE_OVERCLOUD_POST_CONFIGURE:-1}
+
     # Additional arguments to pass to kayobe commands.
     export KAYOBE_EXTRA_ARGS=${KAYOBE_EXTRA_ARGS:-}
 
@@ -349,17 +359,21 @@ function overcloud_deploy {
     if [[ ${KAYOBE_OVERCLOUD_CONTAINER_IMAGE_BUILD} = 1 ]]; then
         echo "Building overcloud container images"
         run_kayobe overcloud container image build
-    else
+    elif [[ ${KAYOBE_OVERCLOUD_CONTAINER_IMAGE_PULL} = 1 ]]; then
         echo "Pulling overcloud container images"
         run_kayobe overcloud container image pull
     fi
 
-    echo "Deploying containerised overcloud services"
-    run_kayobe overcloud service deploy
+    if [[ ${KAYOBE_OVERCLOUD_SERVICE_DEPLOY} = 1 ]]; then
+        echo "Deploying containerised overcloud services"
+        run_kayobe overcloud service deploy
+    fi
 
-    echo "Performing post-deployment configuration"
-    source "${KOLLA_CONFIG_PATH:-/etc/kolla}/admin-openrc.sh"
-    run_kayobe overcloud post configure
+    if [[ ${KAYOBE_OVERCLOUD_POST_CONFIGURE} = 1 ]]; then
+        echo "Performing post-deployment configuration"
+        source "${KOLLA_CONFIG_PATH:-/etc/kolla}/admin-openrc.sh"
+        run_kayobe overcloud post configure
+    fi
 
     echo "Control plane deployment complete"
 }
diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
new file mode 100644
index 0000000000000000000000000000000000000000..51d1119fec7e3f548254dcb48b6c3df058597803
--- /dev/null
+++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
@@ -0,0 +1,128 @@
+---
+# The following configuration aims to test some of the 'host configure'
+# command.
+
+# Additional users.
+controller_users:
+  - username: kayobe-test-user
+    name: Kayobe test user
+    password: kayobe-test-user-password
+    groups:
+      - stack
+
+# Additional network interfaces, testing a variety of interface configurations.
+controller_extra_network_interfaces:
+  - test_net_eth
+  - test_net_eth_vlan
+  - test_net_bridge
+  - test_net_bridge_vlan
+  - test_net_bond
+  - test_net_bond_vlan
+
+# dummy2: Ethernet interface.
+test_net_eth_cidr: 192.168.34.0/24
+test_net_eth_routes:
+  - cidr: 192.168.40.0/24
+    gateway: 192.168.34.254
+test_net_eth_interface: dummy2
+
+# dummy2.42: VLAN subinterface of dummy2.
+test_net_eth_vlan_cidr: 192.168.35.0/24
+test_net_eth_vlan_interface: "{% raw %}{{ test_net_eth_interface }}.{{ test_net_eth_vlan_vlan }}{% endraw %}"
+test_net_eth_vlan_vlan: 42
+
+# br0: bridge with ports dummy3, dummy4.
+test_net_bridge_cidr: 192.168.36.0/24
+test_net_bridge_interface: br0
+test_net_bridge_bridge_ports: [dummy3, dummy4]
+
+# br0.43: VLAN subinterface of br0.
+test_net_bridge_vlan_cidr: 192.168.37.0/24
+test_net_bridge_vlan_interface: "{% raw %}{{ test_net_bridge_interface }}.{{ test_net_bridge_vlan_vlan }}{% endraw %}"
+test_net_bridge_vlan_vlan: 43
+
+# bond0: bond with slaves dummy5, dummy6.
+test_net_bond_cidr: 192.168.38.0/24
+test_net_bond_interface: bond0
+test_net_bond_bond_slaves: [dummy5, dummy6]
+
+# bond0.44: VLAN subinterface of bond0.
+test_net_bond_vlan_cidr: 192.168.39.0/24
+test_net_bond_vlan_interface: "{% raw %}{{ test_net_bond_interface }}.{{ test_net_bond_vlan_vlan }}{% endraw %}"
+test_net_bond_vlan_vlan: 44
+
+# Create an LVM volume group for Docker volumes and devicemapper.
+controller_lvm_groups:
+  - "{% raw %}{{ controller_lvm_group_data }}{% endraw %}"
+
+# Provide a disk for use by LVM. Uses the software RAID device created below.
+controller_lvm_group_data_disks:
+  - /dev/md0
+
+# Define a software RAID device consisting of two loopback devices.
+controller_mdadm_arrays:
+  - name: md0
+    devices:
+      - /dev/loop0
+      - /dev/loop1
+    level: '1'
+    state: present
+
+# Set a sysctl.
+controller_sysctl_parameters:
+  fs.mount-max: 99999
+
+# Disable cloud-init.
+disable_cloud_init: true
+
+# Use devicemapper storage driver.
+docker_storage_driver: devicemapper
+
+# Set Honolulu time.
+timezone: Pacific/Honolulu
+
+{% if ansible_os_family == 'RedHat' %}
+{% if ansible_distribution_major_version | int == 7 %}
+# Use a local Yum mirror.
+yum_use_local_mirror: true
+# Mirror FQDN for Yum repos.
+yum_centos_mirror_host: "{{ zuul_site_mirror_fqdn }}"
+# Mirror directory for Yum CentOS repos.
+yum_centos_mirror_directory: 'centos'
+# Mirror FQDN for Yum EPEL repos.
+yum_epel_mirror_host: "{{ zuul_site_mirror_fqdn }}"
+# Mirror directory for Yum EPEL repos.
+yum_epel_mirror_directory: 'epel'
+# Configure a custom Yum repository.
+yum_custom_repos:
+  td-agent:
+    baseurl: http://packages.treasuredata.com/3/redhat/$releasever/$basearch
+    gpgkey: https://packages.treasuredata.com/GPG-KEY-td-agent
+    gpgcheck: yes
+# Don't install EPEL repositories.
+yum_install_epel: false
+# Enable yum-cron.
+yum_cron_enabled: true
+{% else %}
+# Use a local DNF mirror.
+dnf_use_local_mirror: true
+# Mirror FQDN for DNF repos.
+dnf_centos_mirror_host: "{{ zuul_site_mirror_fqdn }}"
+# Mirror directory for DNF CentOS repos.
+dnf_centos_mirror_directory: 'centos'
+# Mirror FQDN for DNF EPEL repos.
+dnf_epel_mirror_host: "{{ zuul_site_mirror_fqdn }}"
+# Mirror directory for DNF EPEL repos.
+dnf_epel_mirror_directory: 'epel'
+# Configure a custom DNF repository.
+dnf_custom_repos:
+  td-agent:
+    baseurl: http://packages.treasuredata.com/3/redhat/$releasever/$basearch
+    gpgkey: https://packages.treasuredata.com/GPG-KEY-td-agent
+    gpgcheck: yes
+# Don't install EPEL repositories.
+dnf_install_epel: false
+# Enable DNF Automatic.
+dnf_automatic_enabled: true
+{% endif %}
+{% endif %}
diff --git a/playbooks/kayobe-overcloud-host-configure-base/pre.yml b/playbooks/kayobe-overcloud-host-configure-base/pre.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c1a372a767f87c00da0c2d8e1275c580731aa0a8
--- /dev/null
+++ b/playbooks/kayobe-overcloud-host-configure-base/pre.yml
@@ -0,0 +1,42 @@
+---
+- hosts: primary
+  vars:
+    testinfra_venv: ~/testinfra-venv
+  tasks:
+    - name: Ensure python3 is installed
+      package:
+        name: python3
+      become: true
+
+    - name: Ensure testinfra is installed
+      pip:
+        name:
+          - distro
+          - testinfra
+          - pytest-html
+        virtualenv: "{{ testinfra_venv }}"
+        virtualenv_python: python3
+
+    # NOTE(mgoddard): Use the name zzz-overrides.yml to ensure this takes
+    # precedence over the standard config files and zz-overrides.yml from
+    # kayobe-overcloud-base.
+    - name: Ensure kayobe-config override config file exists
+      template:
+        src: overrides.yml.j2
+        dest: "{{ kayobe_config_src_dir }}/etc/kayobe/zzz-overrides.yml"
+
+    # NOTE(mgoddard): Create two loopback devices backed by files. These will
+    # be added to a software RAID volume, then added to an LVM volume group.
+    - name: Ensure a docker storage backing file exists
+      command: truncate -s 2G /tmp/docker-storage{{ item }}
+      loop: [0, 1]
+
+    - name: Ensure the docker storage loopback device is created
+      command: losetup /dev/loop{{ item }} /tmp/docker-storage{{ item }}
+      become: true
+      loop: [0, 1]
+
+    - name: Ensure dummy network interfaces exist
+      command: ip link add dummy{{ item }} type dummy
+      become: true
+      loop: "{{ range(2, 7) | list }}"
diff --git a/playbooks/kayobe-overcloud-host-configure-base/run.yml b/playbooks/kayobe-overcloud-host-configure-base/run.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1da3d336e68b1c5b4a1968151809ba83351e37d9
--- /dev/null
+++ b/playbooks/kayobe-overcloud-host-configure-base/run.yml
@@ -0,0 +1,20 @@
+---
+- hosts: primary
+  environment:
+    KAYOBE_CONFIG_SOURCE_PATH: "{{ kayobe_config_src_dir }}"
+    # Don't run container deployment.
+    KAYOBE_OVERCLOUD_CONTAINER_IMAGE_PULL: 0
+    KAYOBE_OVERCLOUD_SERVICE_DEPLOY: 0
+    KAYOBE_OVERCLOUD_POST_CONFIGURE: 0
+  vars:
+    testinfra_venv: ~/testinfra-venv
+    test_path: "{{ kayobe_src_dir }}/playbooks/kayobe-overcloud-host-configure-base/tests/"
+  tasks:
+    - name: Ensure overcloud is deployed
+      shell:
+        cmd: "{{ kayobe_src_dir }}/dev/overcloud-deploy.sh > {{ logs_dir }}/ansible/overcloud-deploy"
+
+    - name: Run testinfra tests
+      command: "{{ testinfra_venv }}/bin/py.test {{ test_path }} --html={{ logs_dir }}/test-results.html --self-contained-html"
+      environment:
+        SITE_MIRROR_FQDN: "{{ zuul_site_mirror_fqdn }}"
diff --git a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f55ba999a3bc7705d370a2bb7e561991826f451
--- /dev/null
+++ b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+
+# Kayobe overcloud host configure tests.
+# Uses py.test and TestInfra.
+
+import ipaddress
+import os
+
+import distro
+import pytest
+
+
+def _is_yum():
+    info = distro.linux_distribution()
+    return info[0] == 'CentOS Linux' and info[1].startswith('7')
+
+
+def _is_dnf():
+    info = distro.linux_distribution()
+    return info[0] == 'CentOS Linux' and info[1].startswith('8')
+
+
+def test_network_ethernet(host):
+    interface = host.interface('dummy2')
+    assert interface.exists
+    assert '192.168.34.1' in interface.addresses
+    routes = host.check_output('/sbin/ip route show dev dummy2')
+    assert '192.168.40.0/24 via 192.168.34.254' in routes
+
+
+def test_network_ethernet_vlan(host):
+    interface = host.interface('dummy2.42')
+    assert interface.exists
+    assert '192.168.35.1' in interface.addresses
+    assert host.file('/sys/class/net/dummy2.42/lower_dummy2').exists
+
+
+def test_network_bridge(host):
+    interface = host.interface('br0')
+    assert interface.exists
+    assert '192.168.36.1' in interface.addresses
+    ports = ['dummy3', 'dummy4']
+    sys_ports = host.check_output('ls -1 /sys/class/net/br0/brif')
+    assert sys_ports == "\n".join(ports)
+    for port in ports:
+       interface = host.interface(port)
+       assert interface.exists
+       v4_addresses = [a for a in interface.addresses
+                       if ipaddress.ip_address(a).version == '4']
+       assert not v4_addresses
+
+
+def test_network_bridge_vlan(host):
+    interface = host.interface('br0.43')
+    assert interface.exists
+    assert '192.168.37.1' in interface.addresses
+    assert host.file('/sys/class/net/br0.43/lower_br0').exists
+
+
+def test_network_bond(host):
+    interface = host.interface('bond0')
+    assert interface.exists
+    assert '192.168.38.1' in interface.addresses
+    sys_slaves = host.check_output('cat /sys/class/net/bond0/bonding/slaves')
+    slaves = ['dummy5', 'dummy6']
+    assert sys_slaves == " ".join(slaves)
+    for slave in slaves:
+       interface = host.interface(slave)
+       assert interface.exists
+       assert not interface.addresses
+
+
+def test_network_bond_vlan(host):
+    interface = host.interface('bond0.44')
+    assert interface.exists
+    assert '192.168.39.1' in interface.addresses
+    assert host.file('/sys/class/net/bond0.44/lower_bond0').exists
+
+
+def test_additional_user_account(host):
+      user = host.user("kayobe-test-user")
+      assert user.name == "kayobe-test-user"
+      assert user.group == "kayobe-test-user"
+      assert set(user.groups) == {"kayobe-test-user", "stack"}
+      assert user.gecos == "Kayobe test user"
+      with host.sudo():
+          assert user.password == 'kayobe-test-user-password'
+
+
+def test_software_RAID(host):
+    slaves = host.check_output("ls -1 /sys/class/block/md0/slaves/")
+    assert slaves == "loop0\nloop1"
+
+
+def test_sysctls(host):
+    assert host.sysctl("fs.mount-max") == 99999
+
+
+def test_cloud_init_is_disabled(host):
+    assert host.file("/etc/cloud/cloud-init.disabled").exists
+
+
+def test_docker_storage_driver_is_devicemapper(host):
+    with host.sudo("stack"):
+        info = host.check_output("docker info")
+    assert "devicemapper" in info
+
+
+@pytest.mark.parametrize('user', ['kolla', 'stack'])
+def test_docker_image_download(host, user):
+    with host.sudo(user):
+        host.check_output("docker pull alpine")
+
+
+@pytest.mark.parametrize('user', ['kolla', 'stack'])
+def test_docker_container_run(host, user):
+    with host.sudo(user):
+        host.check_output("docker run --rm alpine /bin/true")
+
+
+def test_timezone(host):
+    status = host.check_output("timedatectl status")
+    assert "Pacific/Honolulu" in status
+
+
+@pytest.mark.parametrize('repo', ["base", "extras", "updates", "epel"])
+@pytest.mark.skipif(not _is_yum(), reason="Yum only supported on CentOS 7")
+def test_yum_local_package_mirrors(host, repo):
+    assert os.getenv('SITE_MIRROR_FQDN')
+    info = host.check_output("yum repoinfo %s", repo)
+    assert os.getenv('SITE_MIRROR_FQDN') in info
+
+
+@pytest.mark.parametrize('repo', ["AppStream", "BaseOS", "Extras", "epel",
+                                  "epel-modular"])
+@pytest.mark.skipif(not _is_dnf(), reason="DNF only supported on CentOS 8")
+def test_dnf_local_package_mirrors(host, repo):
+    # Depends on SITE_MIRROR_FQDN environment variable.
+    assert os.getenv('SITE_MIRROR_FQDN')
+    # NOTE(mgoddard): Should not require sudo but some files
+    # (/var/cache/dnf/expired_repos.json) can have incorrect permissions.
+    # https://bugzilla.redhat.com/show_bug.cgi?id=1636909
+    with host.sudo():
+        info = host.check_output("dnf repoinfo %s", repo)
+    assert os.getenv('SITE_MIRROR_FQDN') in info
+
+
+@pytest.mark.skipif(not _is_yum(), reason="YUM only supported on CentOS 7")
+def test_yum_custom_package_repository_is_available(host):
+    with host.sudo():
+        host.check_output("yum -y install td-agent")
+    assert host.package("td-agent").is_installed
+
+
+@pytest.mark.skipif(not _is_dnf(), reason="DNF only supported on CentOS 8")
+def test_dnf_custom_package_repository_is_available(host):
+    with host.sudo():
+        host.check_output("dnf -y install td-agent")
+    assert host.package("td-agent").is_installed
+
+
+@pytest.mark.skipif(not _is_yum(), reason="YUM only supported on CentOS 7")
+def test_yum_cron(host):
+    assert host.package("yum-cron").is_installed
+    assert host.service("yum-cron").is_enabled
+    assert host.service("yum-cron").is_running
+
+
+@pytest.mark.skipif(not _is_dnf(), reason="DNF only supported on CentOS 8")
+def test_dnf_automatic(host):
+    assert host.package("dnf-automatic").is_installed
+    assert host.service("dnf-automatic.timer").is_enabled
+    assert host.service("dnf-automatic.timer").is_running
diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml
index c081039446c3cb648614cf1f2e8700597961f7e2..bca1f29a522cd8029d6dd813ae8cf06d533495aa 100644
--- a/zuul.d/jobs.yaml
+++ b/zuul.d/jobs.yaml
@@ -130,6 +130,22 @@
     parent: kayobe-seed-base
     nodeset: kayobe-centos8
 
+- job:
+    name: kayobe-overcloud-host-configure-base
+    parent: kayobe-overcloud-base
+    description: |
+      Base job for testing overcloud host configure.
+
+      Configures the primary VM as an overcloud controller.
+    pre-run: playbooks/kayobe-overcloud-host-configure-base/pre.yml
+    run: playbooks/kayobe-overcloud-host-configure-base/run.yml
+    timeout: 7200
+
+- job:
+    name: kayobe-overcloud-host-configure-centos8
+    parent: kayobe-overcloud-host-configure-base
+    nodeset: kayobe-centos8
+
 - job:
     name: kayobe-seed-upgrade-base
     parent: kayobe-base
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index 964203002c2597fc13e54afb165be5d561ef891f..45b76d8826ad8a117eb934eb131b6d4aa3074442 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -11,6 +11,7 @@
         - kayobe-tox-ansible
         - kayobe-tox-molecule
         - kayobe-overcloud-centos8
+        - kayobe-overcloud-host-configure-centos8
         - kayobe-overcloud-upgrade-centos8
         - kayobe-seed-centos8
         - kayobe-seed-upgrade-centos8
@@ -22,6 +23,7 @@
         - kayobe-tox-ansible
         - kayobe-tox-molecule
         - kayobe-overcloud-centos8
+        - kayobe-overcloud-host-configure-centos8
         - kayobe-overcloud-upgrade-centos8
         - kayobe-seed-centos8
         - kayobe-seed-upgrade-centos8