diff --git a/ansible/filter_plugins/networkd.py b/ansible/filter_plugins/networkd.py
new file mode 100644
index 0000000000000000000000000000000000000000..6321e33d67373b9d20f88ec5b8bd3e338a5f9f5e
--- /dev/null
+++ b/ansible/filter_plugins/networkd.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2021 StackHPC Ltd.
+#
+# 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.
+
+from kayobe.plugins.filter import networkd
+
+
+class FilterModule(object):
+    """Systemd-networkd filters."""
+
+    def filters(self):
+        return networkd.get_filters()
diff --git a/ansible/group_vars/all/network b/ansible/group_vars/all/network
index 998a2d823bd512c6c53c56cfdcdfcec06cf5fe05..dbb2637124575ccd3c79b6cbce2c8c489cfdf7e0 100644
--- a/ansible/group_vars/all/network
+++ b/ansible/group_vars/all/network
@@ -86,3 +86,9 @@ network_patch_suffix_ovs: '-ovs'
 # List of IP routing tables. Each item should be a dict containing 'id' and
 # 'name' items. These tables will be added to /etc/iproute2/rt_tables.
 network_route_tables: []
+
+###############################################################################
+# Systemd-networkd configuration.
+
+# Prefix for systemd-networkd configuration file names.
+networkd_prefix: "50-kayobe-"
diff --git a/ansible/roles/network-debian/handlers/main.yml b/ansible/roles/network-debian/handlers/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..04bf83cc61c78145025e414988ce8986fe8fd70d
--- /dev/null
+++ b/ansible/roles/network-debian/handlers/main.yml
@@ -0,0 +1,16 @@
+---
+- name: Find netplan systemd-networkd configuration
+  become: true
+  find:
+    path: /run/systemd/network
+  register: netplan_systemd_networkd_config
+  listen: Remove netplan systemd-networkd configuration
+
+- name: Remove netplan systemd-networkd configuration
+  become: true
+  file:
+    path: "{{ item.path }}"
+    state: absent
+  loop: "{{ netplan_systemd_networkd_config.files }}"
+  loop_control:
+    label: "{{ item.path }}"
diff --git a/ansible/roles/network-debian/tasks/main.yml b/ansible/roles/network-debian/tasks/main.yml
index 06f3c83946d6fd37ee4cb95afd7bbf6ceec37a24..76c74635bd5066758b5b66f9429ecf126b05ee5e 100644
--- a/ansible/roles/network-debian/tasks/main.yml
+++ b/ansible/roles/network-debian/tasks/main.yml
@@ -1,51 +1,29 @@
 ---
-- name: Ensure NetworkManager is disabled
-  service:
-    name: NetworkManager
-    state: stopped
-    enabled: no
-  become: True
-  register: nm_result
-  failed_when:
-    - nm_result is failed
-    # Ugh, Ansible's service module doesn't handle uninstalled services.
-    - "'Could not find the requested service' not in nm_result.msg"
-
 - import_role:
     name: ahuffman.resolv
   when: resolv_is_managed | bool
   become: True
 
-- name: Configure network interfaces (RedHat)
-  import_role:
-    name: MichaelRigart.interfaces
-  vars:
-    interfaces_route_tables: "{{ network_route_tables }}"
-    interfaces_ether_interfaces: >
-      {{ network_interfaces |
-         net_select_ethers |
-         map('net_interface_obj') |
-         list }}
-    interfaces_bridge_interfaces: >
-      {{ network_interfaces |
-         net_select_bridges |
-         map('net_bridge_obj') |
-         list }}
-    interfaces_bond_interfaces: >
-      {{ network_interfaces |
-         net_select_bonds |
-         map('net_bond_obj') |
-         list }}
-
-# Ensure that interface bouncing is finished before veth pairs are added,
-# since they are only ephemerally configured on Debian.
-- name: Flush handlers
-  meta: flush_handlers
+- name: Remove netplan.io packages
+  become: true
+  package:
+    name:
+      - libnetplan0
+      - netplan.io
+    state: absent
+  notify:
+    - Remove netplan systemd-networkd configuration
 
-# Configure virtual ethernet patch links to connect the workload provision
-# and external network bridges to the Neutron OVS bridge.
-- name: Ensure OVS patch links exist
+- name: Configure systemd-networkd
   import_role:
-    name: veth
+    name: stackhpc.systemd_networkd
   vars:
-    veth_interfaces: "{{ network_interfaces | net_ovs_veths }}"
+    systemd_networkd_link: "{{ network_interfaces | networkd_links }}"
+    systemd_networkd_netdev: "{{ network_interfaces | networkd_netdevs }}"
+    systemd_networkd_network: "{{ network_interfaces | networkd_networks }}"
+    systemd_networkd_apply_config: true
+    systemd_networkd_enable_resolved: false
+    systemd_networkd_symlink_resolv_conf: false
+    systemd_networkd_cleanup: true
+    systemd_networkd_cleanup_patterns:
+      - "{{ networkd_prefix }}*"
diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst
index 34532570ba11beea383cde22b8d83cb0318661db..e4e685931aa6aeab4b018b273785cc85ff0c3a26 100644
--- a/doc/source/configuration/reference/network.rst
+++ b/doc/source/configuration/reference/network.rst
@@ -58,6 +58,8 @@ supported:
 
     Fully Qualified Domain Name (FQDN) used by API services on this network.
 ``routes``
+    .. note:: ``options`` is not currently supported on Ubuntu.
+
     List of static IP routes. Each item should be a dict containing the
     item ``cidr``, and optionally ``gateway``, ``table`` and ``options``.
     ``cidr`` is the CIDR representation of the route's destination. ``gateway``
@@ -334,11 +336,15 @@ The following attributes are supported:
 ``bond_lacp_rate``
     For bond interfaces, the lacp_rate to use for the bond.
 ``ethtool_opts``
+    .. note:: ``ethtool_opts`` is not currently supported on Ubuntu.
+
     Physical network interface options to apply with ``ethtool``. When used on
     bond and bridge interfaces, settings apply to underlying interfaces. This
     should be a string of arguments passed to the ``ethtool`` utility, for
     example ``"-G ${DEVICE} rx 8192 tx 8192"``.
 ``zone``
+    .. note:: ``zone`` is not currently supported on Ubuntu.
+
     The name of ``firewalld`` zone to be attached to network interface.
 
 IP Addresses
diff --git a/doc/source/contributor/automated.rst b/doc/source/contributor/automated.rst
index 7cbca9135445dc22a2c868de20b66a5aadfbdbf5..e3cd2153eccc72e4878d20a0b687f2b5bb228142 100644
--- a/doc/source/contributor/automated.rst
+++ b/doc/source/contributor/automated.rst
@@ -240,7 +240,7 @@ Alternatively, this can be added using the following commands::
 
     sudo ip l add breth1 type bridge
     sudo ip l set breth1 up
-    sudo ip a add 192.168.33.5/24 dev breth1
+    sudo ip a add 192.168.33.5/24 brd 192.168.33.255 dev breth1
     sudo ip l add eth1 type dummy
     sudo ip l set eth1 up
     sudo ip l set eth1 master breth1
diff --git a/kayobe/plugins/filter/networkd.py b/kayobe/plugins/filter/networkd.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f0934743105ba5c5f31fde6e49229b05fdf8805
--- /dev/null
+++ b/kayobe/plugins/filter/networkd.py
@@ -0,0 +1,571 @@
+# Copyright (c) 2021 StackHPC Ltd.
+#
+# 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.
+
+"""
+This module provides Ansible filters that generate configuration for
+systemd-networkd NetDevs, links and networks. The results are compatible with
+the stackhpc.ansible_role_systemd_networkd role.
+
+Systemd-networkd uses INI-style configuration files, with the provision for
+multiple sections with the same name, and multiple options with the same name
+in a given section. This results in a slightly unwieldy data format used by the
+role. The top level is a list of dicts with section names as keys. The values
+are lists of dicts mapping option names to values.
+
+Example schema (YAML):
+- section1:
+    - option1: value1
+    - option2: value2
+- section2
+    - option3: value3
+"""
+
+import ipaddress
+
+from ansible import errors
+import jinja2
+
+from kayobe.plugins.filter import networks
+from kayobe.plugins.filter import utils
+
+
+def _filter_options(config):
+    """Filter out None values from a networkd config.
+
+    :param config: List of sections to filter.
+    :returns: a filtered list of sections without empty options.
+    """
+    # Example schema (YAML):
+    # - section1:
+    #     - option1: value1
+    #     - option2:
+    # - section2
+    #     - option3:
+    # We can filter this down to the following:
+    # - section1:
+    #     - option1: value1
+    new_config = []
+    for section_dict in config:
+        new_section_dict = {}
+        for section_name, section in section_dict.items():
+            new_section = []
+            for option_dict in section:
+                new_option_dict = {}
+                for option_name, option in option_dict.items():
+                    if option is not None:
+                        new_option_dict[option_name] = option
+                if new_option_dict:
+                    new_section.append(new_option_dict)
+            if new_section:
+                new_section_dict[section_name] = new_section
+        if new_section_dict:
+            new_config.append(new_section_dict)
+    return new_config
+
+
+def _ms_to_s(n):
+    """Convert from milliseconds to seconds."""
+    if n is not None:
+        n = float(n) / 1000
+    return n
+
+
+def _vlan_netdev(context, name, inventory_hostname):
+    """Return a networkd NetDev configuration for a VLAN interface.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    device = networks.net_interface(context, name, inventory_hostname)
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    vlan = networks.net_vlan(context, name, inventory_hostname)
+    config = [
+        {
+            'NetDev': [
+                {'Name': device},
+                {'Kind': 'vlan'},
+                {'MTUBytes': mtu},
+            ],
+        },
+        {
+            'VLAN': [
+                {'Id': vlan},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _bridge_netdev(context, name, inventory_hostname):
+    """Return a networkd NetDev configuration for a bridge.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    device = networks.net_interface(context, name, inventory_hostname)
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    config = [
+        {
+            'NetDev': [
+                {'Name': device},
+                {'Kind': 'bridge'},
+                {'MTUBytes': mtu},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _bond_netdev(context, name, inventory_hostname):
+    """Return a networkd NetDev configuration for a bond.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    device = networks.net_interface(context, name, inventory_hostname)
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    mode = networks.net_bond_mode(context, name, inventory_hostname)
+    miimon = networks.net_bond_miimon(context, name, inventory_hostname)
+    updelay = networks.net_bond_updelay(context, name, inventory_hostname)
+    downdelay = networks.net_bond_downdelay(context, name, inventory_hostname)
+    xmit_hash_policy = networks.net_bond_xmit_hash_policy(context, name,
+                                                          inventory_hostname)
+    lacp_rate = networks.net_bond_lacp_rate(context, name, inventory_hostname)
+    config = [
+        {
+            'NetDev': [
+                {'Name': device},
+                {'Kind': 'bond'},
+                {'MTUBytes': mtu},
+            ]
+        },
+        {
+            'Bond': [
+                {'Mode': mode},
+                {'TransmitHashPolicy': xmit_hash_policy},
+                {'LACPTransmitRate': lacp_rate},
+                {'MIIMonitorSec': _ms_to_s(miimon)},
+                {'UpDelaySec': _ms_to_s(updelay)},
+                {'DownDelaySec': _ms_to_s(downdelay)},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _veth_netdev(context, veth, inventory_hostname):
+    """Return a networkd NetDev configuration for a veth pair.
+
+    :param context: a Jinja2 Context object.
+    :param veth: a dict describing the virtual Ethernet pair.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    interface = veth['name']
+    peer = veth['peer']
+    mtu = veth['mtu']
+    config = [
+        {
+            'NetDev': [
+                {'Name': interface},
+                {'Kind': 'veth'},
+                {'MTUBytes': mtu},
+            ],
+        },
+        {
+            'Peer': [
+                {'Name': peer},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _network(context, name, inventory_hostname, bridge, bond, vlan_interfaces):
+    """Return a networkd network for an interface.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param inventory_hostname: Ansible inventory hostname.
+    :param bridge: Name of a bridge into which the interface is plugged, or
+                   None.
+    :param bond: Name of a bond of which the interface is a member, or None.
+    :param vlan_interfaces: List of VLAN subinterfaces of the interface.
+    """
+    # FIXME(mgoddard): Currently does not support: rules, ethtool_opts, zone,
+    # allowed_addresses.
+    device = networks.net_interface(context, name, inventory_hostname)
+    ip = networks.net_ip(context, name, inventory_hostname)
+    cidr = networks.net_cidr(context, name, inventory_hostname)
+    gateway = networks.net_gateway(context, name, inventory_hostname)
+    if ip is None:
+        gateway = None
+    else:
+        if not cidr:
+            raise errors.AnsibleFilterError(
+                "No CIDR attribute configured for '%s' network but it has an "
+                "IP address" %
+                (name))
+        ip = "%s/%s" % (ip, ipaddress.ip_network(cidr).prefixlen)
+
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    routes = networks.net_routes(context, name, inventory_hostname)
+    bootproto = networks.net_bootproto(context, name, inventory_hostname)
+    defroute = networks.net_defroute(context, name, inventory_hostname)
+    if defroute is not None:
+        defroute = utils.call_bool_filter(context, defroute)
+    config = [
+        {
+            'Match': [
+                {'Name': device},
+            ]
+        },
+        {
+            'Network': [
+                {'Address': ip},
+                {'Broadcast': 'true' if ip else None},
+                {'Gateway': gateway},
+                {'DHCP': ('yes' if bootproto and bootproto.lower() == 'dhcp'
+                          else None)},
+                {'UseGateway': ('false'
+                                if defroute is not None and not defroute
+                                else None)},
+                {'Bridge': bridge},
+                {'Bond': bond},
+            ] + [
+                {'VLAN': vlan_interface}
+                for vlan_interface in vlan_interfaces
+            ]
+        },
+        {
+            'Link': [
+                {'MTUBytes': mtu},
+            ]
+        },
+    ]
+    if routes:
+        config += [
+            {
+                'Route': [
+                    # FIXME(mgoddard): No support for 'options'.
+                    {'Destination': route['cidr']},
+                    {'Gateway': route.get('gateway')},
+                ]
+            }
+            for route in routes or []
+        ]
+    return _filter_options(config)
+
+
+def _bridge_port_network(context, name, port, inventory_hostname,
+                         vlan_interfaces):
+    """Return a networkd network configuration for a bridge port.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param port: name of the bridge port interface.
+    :param inventory_hostname: Ansible inventory hostname.
+    :param vlan_interfaces: List of VLAN subinterfaces of the interface.
+    """
+    bridge = networks.get_and_validate_interface(context, name,
+                                                 inventory_hostname)
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    config = [
+        {
+            'Match': [
+                {'Name': port},
+            ]
+        },
+        {
+            'Network': [
+                {'Bridge': bridge},
+            ] + [
+                {'VLAN': vlan_interface}
+                for vlan_interface in vlan_interfaces
+            ]
+        },
+        {
+            'Link': [
+                {'MTUBytes': mtu},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _bond_member_network(context, name, member, inventory_hostname,
+                         vlan_interfaces):
+    """Return a networkd network configuration for a bond member.
+
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
+    :param member: name of the bond member interface.
+    :param inventory_hostname: Ansible inventory hostname.
+    :param vlan_interfaces: List of VLAN subinterfaces of the interface.
+    """
+    bond = networks.get_and_validate_interface(context, name,
+                                               inventory_hostname)
+    mtu = networks.net_mtu(context, name, inventory_hostname)
+    config = [
+        {
+            'Match': [
+                {'Name': member},
+            ]
+        },
+        {
+            'Network': [
+                {'Bond': bond},
+            ] + [
+                {'VLAN': vlan_interface}
+                for vlan_interface in vlan_interfaces
+            ]
+        },
+        {
+            'Link': [
+                {'MTUBytes': mtu},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _veth_network(context, veth, inventory_hostname):
+    """Return a networkd network configuration for a veth link.
+
+    :param context: a Jinja2 Context object.
+    :param veth: a dict describing the virtual Ethernet pair.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    interface = veth['name']
+    bridge = veth['bridge']
+    config = [
+        {
+            'Match': [
+                {'Name': interface},
+            ]
+        },
+        {
+            'Network': [
+                {'Bridge': bridge},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+def _veth_peer_network(context, veth, inventory_hostname):
+    """Return a networkd network configuration for a veth peer.
+
+    :param context: a Jinja2 Context object.
+    :param veth: a dict describing the virtual Ethernet pair.
+    :param inventory_hostname: Ansible inventory hostname.
+    """
+    interface = veth['peer']
+    config = [
+        {
+            'Match': [
+                {'Name': interface},
+            ]
+        },
+        {
+            'Network': [
+                # NOTE(mgoddard): bring the interface up, even without an IP.
+                {'ConfigureWithoutCarrier': 'true'},
+            ]
+        }
+    ]
+    return _filter_options(config)
+
+
+@jinja2.contextfilter
+def networkd_netdevs(context, names, inventory_hostname=None):
+    """Return a dict representation of networkd NetDev configuration.
+
+    The format is compatible with the systemd_networkd_netdev variable in the
+    stackhpc.ansible_role_systemd_networkd role.
+
+    :param context: a Jinja2 Context object.
+    :param names: List of names of networks.
+    :param inventory_hostname: Ansible inventory hostname.
+    :returns: a dict representation of networkd NetDev configuration.
+    """
+    # Prefix for configuration file names.
+    prefix = utils.get_hostvar(context, "networkd_prefix", inventory_hostname)
+
+    result = {}
+
+    # VLANs.
+    for name in networks.net_select_vlans(context, names, inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        netdev = _vlan_netdev(context, name, inventory_hostname)
+        result["%s%s" % (prefix, device)] = netdev
+
+    # Bridges.
+    for name in networks.net_select_bridges(context, names,
+                                            inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        netdev = _bridge_netdev(context, name, inventory_hostname)
+        result["%s%s" % (prefix, device)] = netdev
+
+    # Bonds.
+    for name in networks.net_select_bonds(context, names, inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        netdev = _bond_netdev(context, name, inventory_hostname)
+        result["%s%s" % (prefix, device)] = netdev
+
+    # Virtual Ethernet pairs.
+    veths = networks.get_ovs_veths(context, names, inventory_hostname)
+    for veth in veths:
+        netdev = _veth_netdev(context, veth, inventory_hostname)
+        device = veth['name']
+        result["%s%s" % (prefix, device)] = netdev
+
+    return result
+
+
+@jinja2.contextfilter
+def networkd_links(context, names, inventory_hostname=None):
+    """Return a dict representation of networkd link configuration.
+
+    The format is compatible with the systemd_networkd_link variable in the
+    stackhpc.ansible_role_systemd_networkd role.
+
+    :param context: a Jinja2 Context object.
+    :param names: List of names of networks.
+    :param inventory_hostname: Ansible inventory hostname.
+    :returns: a dict representation of networkd link configuration.
+    """
+    # NOTE(mgoddard): We do not currently support link configuration.
+    return {}
+
+
+@jinja2.contextfilter
+def networkd_networks(context, names, inventory_hostname=None):
+    """Return a dict representation of networkd network configuration.
+
+    The format is compatible with the systemd_networkd_network variable in the
+    stackhpc.ansible_role_systemd_networkd role.
+
+    :param context: a Jinja2 Context object.
+    :param names: List of names of networks.
+    :param inventory_hostname: Ansible inventory hostname.
+    :returns: a dict representation of networkd network configuration.
+    """
+    # TODO(mgoddard): some attributes are currently not supported for
+    # systemd-networkd: rules, route options, ethtool_opts, zone,
+    # allowed addresses
+
+    # Build up some useful mappings.
+    bridge_port_to_bridge = {}
+    bond_member_to_bond = {}
+    interface_to_vlans = {}
+
+    # List of all interfaces.
+    interfaces = [
+        networks.net_interface(context, name, inventory_hostname)
+        for name in names
+    ]
+
+    # Map bridge ports to bridges.
+    for name in networks.net_select_bridges(context, names,
+                                            inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        for port in networks.net_bridge_ports(context, name,
+                                              inventory_hostname):
+            bridge_port_to_bridge[port] = device
+
+    # Map bond members to bonds.
+    for name in networks.net_select_bonds(context, names, inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        for member in networks.net_bond_slaves(context, name,
+                                               inventory_hostname):
+            bond_member_to_bond[member] = device
+
+    # Map interfaces to lists of VLAN subinterfaces.
+    for name in networks.net_select_vlans(context, names, inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        vlan = networks.net_vlan(context, name, inventory_hostname)
+        parent = networks.get_vlan_parent(device, vlan)
+        vlan_interfaces = interface_to_vlans.setdefault(parent, [])
+        vlan_interfaces.append(device)
+
+    # Prefix for configuration file names.
+    prefix = utils.get_hostvar(context, "networkd_prefix", inventory_hostname)
+
+    result = {}
+
+    # Configured networks.
+    for name in names:
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        bridge = bridge_port_to_bridge.get(device)
+        bond = bond_member_to_bond.get(device)
+        vlan_interfaces = interface_to_vlans.get(device, [])
+        net = _network(context, name, inventory_hostname, bridge, bond,
+                       vlan_interfaces)
+        result["%s%s" % (prefix, device)] = net
+
+    # Bridge ports that are not in configured networks.
+    for name in networks.net_select_bridges(context, names,
+                                            inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        bridge_ports = networks.net_bridge_ports(context, name,
+                                                 inventory_hostname)
+        for port in set(bridge_ports) - set(interfaces):
+            vlan_interfaces = interface_to_vlans.get(port, [])
+            netdev = _bridge_port_network(context, name, port,
+                                          inventory_hostname, vlan_interfaces)
+            result["%s%s" % (prefix, port)] = netdev
+
+    # Bond members that are not in configured networks.
+    for name in networks.net_select_bonds(context, names, inventory_hostname):
+        device = networks.get_and_validate_interface(context, name,
+                                                     inventory_hostname)
+        bond_members = networks.net_bond_slaves(context, name,
+                                                inventory_hostname)
+        for member in set(bond_members) - set(interfaces):
+            vlan_interfaces = interface_to_vlans.get(member, [])
+            netdev = _bond_member_network(context, name, member,
+                                          inventory_hostname, vlan_interfaces)
+            result["%s%s" % (prefix, member)] = netdev
+
+    # Virtual Ethernet pairs for Open vSwitch.
+    veths = networks.get_ovs_veths(context, names, inventory_hostname)
+    for veth in veths:
+        net = _veth_network(context, veth, inventory_hostname)
+        device = veth['name']
+        result["%s%s" % (prefix, device)] = net
+
+        net = _veth_peer_network(context, veth, inventory_hostname)
+        device = veth['peer']
+        result["%s%s" % (prefix, device)] = net
+
+    return result
+
+
+def get_filters():
+    return {
+        'networkd_netdevs': networkd_netdevs,
+        'networkd_links': networkd_links,
+        'networkd_networks': networkd_networks,
+    }
diff --git a/kayobe/tests/unit/plugins/filter/test_networkd.py b/kayobe/tests/unit/plugins/filter/test_networkd.py
new file mode 100644
index 0000000000000000000000000000000000000000..8264301a6516635ac4b0169d4e8c4861b2996213
--- /dev/null
+++ b/kayobe/tests/unit/plugins/filter/test_networkd.py
@@ -0,0 +1,748 @@
+# Copyright (c) 2021 StackHPC Ltd.
+#
+# 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 copy
+import unittest
+
+from ansible import errors
+from ansible.plugins.filter.core import to_bool
+import jinja2
+
+from kayobe.plugins.filter import networkd
+
+
+class BaseNetworkdTest(unittest.TestCase):
+
+    maxDiff = 2000
+
+    variables = {
+        # Inventory hostname, used to index IP list.
+        "inventory_hostname": "test-host",
+        # net1: Ethernet on eth0 with IP 1.2.3.4/24.
+        "net1_interface": "eth0",
+        "net1_cidr": "1.2.3.0/24",
+        "net1_ips": {"test-host": "1.2.3.4"},
+        # net2: VLAN on eth0.2 with VLAN 2 on interface eth0.
+        "net2_interface": "eth0.2",
+        "net2_vlan": 2,
+        # net3: bridge on br0 with ports eth0 and eth1.
+        "net3_interface": "br0",
+        "net3_bridge_ports": ["eth0", "eth1"],
+        # net4: bond on bond0 with members eth0 and eth1.
+        "net4_interface": "bond0",
+        "net4_bond_slaves": ["eth0", "eth1"],
+        # Prefix for networkd config file names.
+        "networkd_prefix": "50-kayobe-",
+        # Veth pair patch link prefix and suffix.
+        "network_patch_prefix": "p-",
+        "network_patch_suffix_ovs": "-ovs",
+        "network_patch_suffix_phy": "-phy",
+    }
+
+    def setUp(self):
+        # Bandit complains about Jinja2 autoescaping without nosec.
+        self.env = jinja2.Environment()  # nosec
+        self.env.filters['bool'] = to_bool
+        self.context = self._make_context(self.variables)
+
+    def _make_context(self, parent):
+        return self.env.context_class(
+            self.env, parent=parent, name='dummy', blocks={})
+
+    def _update_context(self, variables):
+        updated_vars = copy.deepcopy(self.variables)
+        updated_vars.update(variables)
+        self.context = self._make_context(updated_vars)
+
+
+class TestNetworkdNetDevs(BaseNetworkdTest):
+
+    def test_empty(self):
+        devs = networkd.networkd_netdevs(self.context, [])
+        self.assertEqual({}, devs)
+
+    def test_vlan(self):
+        devs = networkd.networkd_netdevs(self.context, ["net2"])
+        expected = {
+            "50-kayobe-eth0.2": [
+                {
+                    "NetDev": [
+                        {"Name": "eth0.2"},
+                        {"Kind": "vlan"},
+                    ]
+                },
+                {
+                    "VLAN": [
+                        {"Id": 2},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_vlan_all_options(self):
+        self._update_context({"net2_mtu": 1400})
+        devs = networkd.networkd_netdevs(self.context, ["net2"])
+        expected = {
+            "50-kayobe-eth0.2": [
+                {
+                    "NetDev": [
+                        {"Name": "eth0.2"},
+                        {"Kind": "vlan"},
+                        {"MTUBytes": 1400},
+                    ]
+                },
+                {
+                    "VLAN": [
+                        {"Id": 2},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_vlan_no_interface(self):
+        self._update_context({"net2_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_netdevs, self.context, ["net2"])
+
+    def test_bridge(self):
+        devs = networkd.networkd_netdevs(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "NetDev": [
+                        {"Name": "br0"},
+                        {"Kind": "bridge"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_bridge_all_options(self):
+        self._update_context({"net3_mtu": 1400})
+        devs = networkd.networkd_netdevs(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "NetDev": [
+                        {"Name": "br0"},
+                        {"Kind": "bridge"},
+                        {"MTUBytes": 1400},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_bridge_no_interface(self):
+        self._update_context({"net3_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_netdevs, self.context, ["net3"])
+
+    def test_bond(self):
+        devs = networkd.networkd_netdevs(self.context, ["net4"])
+        expected = {
+            "50-kayobe-bond0": [
+                {
+                    "NetDev": [
+                        {"Name": "bond0"},
+                        {"Kind": "bond"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_bond_all_options(self):
+        self._update_context({
+            "net4_mtu": 1400,
+            "net4_bond_mode": "802.3ad",
+            "net4_bond_miimon": 100,
+            "net4_bond_updelay": 200,
+            "net4_bond_downdelay": 300,
+            "net4_bond_xmit_hash_policy": "layer3+4",
+            "net4_bond_lacp_rate": 60,
+        })
+        devs = networkd.networkd_netdevs(self.context, ["net4"])
+        expected = {
+            "50-kayobe-bond0": [
+                {
+                    "NetDev": [
+                        {"Name": "bond0"},
+                        {"Kind": "bond"},
+                        {"MTUBytes": 1400},
+                    ]
+                },
+                {
+                    "Bond": [
+                        {"Mode": "802.3ad"},
+                        {"TransmitHashPolicy": "layer3+4"},
+                        {"LACPTransmitRate": 60},
+                        {"MIIMonitorSec": 0.1},
+                        {"UpDelaySec": 0.2},
+                        {"DownDelaySec": 0.3},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_bond_no_interface(self):
+        self._update_context({"net4_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_netdevs, self.context, ["net4"])
+
+    def test_veth(self):
+        self._update_context({"external_net_names": ["net3"]})
+        devs = networkd.networkd_netdevs(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "NetDev": [
+                        {"Name": "br0"},
+                        {"Kind": "bridge"},
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-phy": [
+                {
+                    "NetDev": [
+                        {"Name": "p-br0-phy"},
+                        {"Kind": "veth"},
+                    ]
+                },
+                {
+                    "Peer": [
+                        {"Name": "p-br0-ovs"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_veth_with_mtu(self):
+        self._update_context({"external_net_names": ["net3"],
+                              "net3_mtu": 1400})
+        devs = networkd.networkd_netdevs(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "NetDev": [
+                        {"Name": "br0"},
+                        {"Kind": "bridge"},
+                        {"MTUBytes": 1400},
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-phy": [
+                {
+                    "NetDev": [
+                        {"Name": "p-br0-phy"},
+                        {"Kind": "veth"},
+                        {"MTUBytes": 1400},
+                    ]
+                },
+                {
+                    "Peer": [
+                        {"Name": "p-br0-ovs"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
+    def test_veth_no_interface(self):
+        self._update_context({"external_net_names": ["net3"],
+                              "net3_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_netdevs, self.context, ["net3"])
+
+
+class TestNetworkdLinks(BaseNetworkdTest):
+
+    def test_empty(self):
+        links = networkd.networkd_links(self.context, ['net1'])
+        self.assertEqual({}, links)
+
+
+class TestNetworkdNetworks(BaseNetworkdTest):
+
+    def test_empty(self):
+        nets = networkd.networkd_networks(self.context, [])
+        self.assertEqual({}, nets)
+
+    def test_eth(self):
+        nets = networkd.networkd_networks(self.context, ["net1"])
+        expected = {
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Address": "1.2.3.4/24"},
+                        {"Broadcast": "true"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_eth_all_options(self):
+        self._update_context({
+            "net1_gateway": "1.2.3.1",
+            "net1_mtu": 1400,
+            "net1_routes": [
+                {
+                    "cidr": "1.2.4.0/24",
+                },
+                {
+                    "cidr": "1.2.5.0/24",
+                    "gateway": "1.2.5.1",
+                },
+                {
+                    "cidr": "1.2.6.0/24",
+                },
+            ],
+            "net1_bootproto": "dhcp",
+            "net1_defroute": 'no',
+        })
+        nets = networkd.networkd_networks(self.context, ["net1"])
+        expected = {
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Address": "1.2.3.4/24"},
+                        {"Broadcast": "true"},
+                        {"Gateway": "1.2.3.1"},
+                        {"DHCP": "yes"},
+                        {'UseGateway': "false"},
+                    ]
+                },
+                {
+                    "Link": [
+                        {"MTUBytes": 1400},
+                    ]
+                },
+                {
+                    "Route": [
+                        {"Destination": "1.2.4.0/24"},
+                    ]
+                },
+                {
+                    "Route": [
+                        {"Destination": "1.2.5.0/24"},
+                        {"Gateway": "1.2.5.1"},
+                    ]
+                },
+                {
+                    "Route": [
+                        {"Destination": "1.2.6.0/24"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_eth_no_interface(self):
+        self._update_context({"net1_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_networks, self.context, ["net1"])
+
+    def test_vlan(self):
+        nets = networkd.networkd_networks(self.context, ["net2"])
+        expected = {
+            "50-kayobe-eth0.2": [
+                {
+                    "Match": [
+                        {"Name": "eth0.2"}
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_vlan_with_parent(self):
+        nets = networkd.networkd_networks(self.context, ["net1", "net2"])
+        expected = {
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Address": "1.2.3.4/24"},
+                        {"Broadcast": "true"},
+                        {"VLAN": "eth0.2"},
+                    ]
+                },
+            ],
+            "50-kayobe-eth0.2": [
+                {
+                    "Match": [
+                        {"Name": "eth0.2"}
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_vlan_no_interface(self):
+        self._update_context({"net2_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_networks, self.context, ["net2"])
+
+    def test_bridge(self):
+        nets = networkd.networkd_networks(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "Match": [
+                        {"Name": "br0"}
+                    ]
+                },
+            ],
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+            ],
+            "50-kayobe-eth1": [
+                {
+                    "Match": [
+                        {"Name": "eth1"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_bridge_with_bridge_port_net(self):
+        # Test the case where a bridge port interface is a Kayobe network
+        # (here, eth0 is net1).
+        self._update_context({
+            "net1_mtu": 1400,
+            "net1_ips": None,
+        })
+        nets = networkd.networkd_networks(self.context, ["net1", "net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "Match": [
+                        {"Name": "br0"}
+                    ]
+                },
+            ],
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+                {
+                    "Link": [
+                        {"MTUBytes": 1400},
+                    ]
+                },
+            ],
+            "50-kayobe-eth1": [
+                {
+                    "Match": [
+                        {"Name": "eth1"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_bridge_no_interface(self):
+        self._update_context({"net3_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_networks, self.context, ["net3"])
+
+    def test_bond(self):
+        nets = networkd.networkd_networks(self.context, ["net4"])
+        expected = {
+            "50-kayobe-bond0": [
+                {
+                    "Match": [
+                        {"Name": "bond0"}
+                    ]
+                },
+            ],
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bond": "bond0"},
+                    ]
+                },
+            ],
+            "50-kayobe-eth1": [
+                {
+                    "Match": [
+                        {"Name": "eth1"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bond": "bond0"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_bond_with_bond_member_net(self):
+        # Test the case where a bond member interface is a Kayobe network
+        # (here, eth0 is net1).
+        self._update_context({
+            "net1_mtu": 1400,
+            "net1_ips": None,
+        })
+        nets = networkd.networkd_networks(self.context, ["net1", "net4"])
+        expected = {
+            "50-kayobe-bond0": [
+                {
+                    "Match": [
+                        {"Name": "bond0"}
+                    ]
+                },
+            ],
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bond": "bond0"},
+                    ]
+                },
+                {
+                    "Link": [
+                        {"MTUBytes": 1400},
+                    ]
+                },
+            ],
+            "50-kayobe-eth1": [
+                {
+                    "Match": [
+                        {"Name": "eth1"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bond": "bond0"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_bond_no_interface(self):
+        self._update_context({"net4_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_networks, self.context, ["net4"])
+
+    def test_veth(self):
+        self._update_context({"external_net_names": ["net3"],
+                              "net3_bridge_ports": []})
+        nets = networkd.networkd_networks(self.context, ["net3"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "Match": [
+                        {"Name": "br0"}
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-phy": [
+                {
+                    "Match": [
+                        {"Name": "p-br0-phy"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-ovs": [
+                {
+                    "Match": [
+                        {"Name": "p-br0-ovs"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"ConfigureWithoutCarrier": "true"},
+                    ]
+                },
+            ],
+        }
+        self.assertEqual(expected, nets)
+
+    def test_veth_on_vlan(self):
+        # Test the case where a VLAN interface is one of the networks that
+        # needs patching to OVS. The parent interface is a bridge, and the veth
+        # pair should be plugged into it.
+        self._update_context({
+            "provision_wl_net_name": "net5",
+            "net3_bridge_ports": [],
+            "net5_interface": "br0.42",
+            "net5_vlan": 42})
+        nets = networkd.networkd_networks(self.context, ["net3", "net5"])
+        expected = {
+            "50-kayobe-br0": [
+                {
+                    "Match": [
+                        {"Name": "br0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"VLAN": "br0.42"}
+                    ]
+                }
+            ],
+            "50-kayobe-br0.42": [
+                {
+                    "Match": [
+                        {"Name": "br0.42"}
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-phy": [
+                {
+                    "Match": [
+                        {"Name": "p-br0-phy"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Bridge": "br0"},
+                    ]
+                },
+            ],
+            "50-kayobe-p-br0-ovs": [
+                {
+                    "Match": [
+                        {"Name": "p-br0-ovs"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"ConfigureWithoutCarrier": "true"},
+                    ]
+                },
+            ],
+        }
+        self.assertEqual(expected, nets)
+
+    def test_veth_no_interface(self):
+        self._update_context({"external_net_names": ["net3"],
+                              "net3_interface": None})
+        self.assertRaises(errors.AnsibleFilterError,
+                          networkd.networkd_networks, self.context, ["net3"])
+
+    def test_no_veth_without_bridge(self):
+        self._update_context({"external_net_names": ["net1"]})
+        nets = networkd.networkd_networks(self.context, ["net1"])
+        expected = {
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Address": "1.2.3.4/24"},
+                        {"Broadcast": "true"},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
+
+    def test_no_veth_on_vlan_without_bridge(self):
+        # Test the case where a VLAN interface is one of the networks that
+        # needs patching to OVS. The parent interface is a bridge, and the veth
+        # pair should be plugged into it.
+        self._update_context({"provision_wl_net": "net2"})
+        nets = networkd.networkd_networks(self.context, ["net1", "net2"])
+        expected = {
+            "50-kayobe-eth0": [
+                {
+                    "Match": [
+                        {"Name": "eth0"}
+                    ]
+                },
+                {
+                    "Network": [
+                        {"Address": "1.2.3.4/24"},
+                        {"Broadcast": "true"},
+                        {"VLAN": "eth0.2"},
+                    ]
+                },
+            ],
+            "50-kayobe-eth0.2": [
+                {
+                    "Match": [
+                        {"Name": "eth0.2"}
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, nets)
diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
index 6b11a421dab1311a2a1e4281f3ca25e12b1ac146..7ffe1ef65d33a0a382e75daeb96d79446b622bd0 100644
--- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
+++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
@@ -16,11 +16,8 @@ controller_extra_network_interfaces:
   - test_net_eth_vlan
   - test_net_bridge
   - test_net_bridge_vlan
-{# Bond configuration does not seem to work with dummy interfaces on Ubuntu #}
-{% if ansible_os_family != 'Debian' %}
   - test_net_bond
   - test_net_bond_vlan
-{% endif %}
 
 # dummy2: Ethernet interface.
 test_net_eth_cidr: 192.168.34.0/24
@@ -44,7 +41,6 @@ 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
 
-{% if ansible_os_family != 'Debian' %}
 # bond0: bond with slaves dummy5, dummy6.
 test_net_bond_cidr: 192.168.38.0/24
 test_net_bond_interface: bond0
@@ -54,7 +50,6 @@ test_net_bond_bond_slaves: [dummy5, dummy6]
 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
-{% endif %}
 
 # Define a software RAID device consisting of two loopback devices.
 controller_mdadm_arrays:
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
index aa91430fd6f523c93585563805e5975aa05497d2..6f44c59e899af51771fd12817855fa752b661ea9 100644
--- 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
@@ -15,13 +15,6 @@ def _is_dnf():
     return info[0] == 'CentOS Linux' and info[1].startswith('8')
 
 
-def _supports_bonds():
-    # Bond configuration does not currently work on Ubuntu when using dummy
-    # devices as slaves.
-    info = distro.linux_distribution()
-    return info[0] != 'Ubuntu'
-
-
 def test_network_ethernet(host):
     interface = host.interface('dummy2')
     assert interface.exists
@@ -59,21 +52,21 @@ def test_network_bridge_vlan(host):
     assert host.file('/sys/class/net/br0.43/lower_br0').exists
 
 
-@pytest.mark.skipif(not _supports_bonds(), reason="Bonding no worky on Ubuntu")
 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)
+    # Ordering is not guaranteed, so compare sets.
+    sys_slaves = set(sys_slaves.split())
+    slaves = set(['dummy5', 'dummy6'])
+    assert sys_slaves == slaves
     for slave in slaves:
        interface = host.interface(slave)
        assert interface.exists
        assert not interface.addresses
 
 
-@pytest.mark.skipif(not _supports_bonds(), reason="Bonding no worky on Ubuntu")
 def test_network_bond_vlan(host):
     interface = host.interface('bond0.44')
     assert interface.exists
diff --git a/requirements.yml b/requirements.yml
index 8aced84e34f7422d73340b35cf538bfd43659c7b..bc054a4eff064a51558e8c6363ea266a45c1c990 100644
--- a/requirements.yml
+++ b/requirements.yml
@@ -1,6 +1,8 @@
 ---
 - src: ahuffman.resolv
   version: 1.3.1
+- src: stackhpc.systemd_networkd
+  version: v1.0.1
 - src: jriguera.configdrive
   # There are no versioned releases of this role.
   version: 8438592c84585c86e62ae07e526d3da53629b377
diff --git a/roles/kayobe-network-bootstrap/tasks/Debian.yml b/roles/kayobe-network-bootstrap/tasks/Debian.yml
deleted file mode 100644
index 4a5d2476d8b804945e1d8b3fdae7982eacf6c0d8..0000000000000000000000000000000000000000
--- a/roles/kayobe-network-bootstrap/tasks/Debian.yml
+++ /dev/null
@@ -1,38 +0,0 @@
----
-- name: Ensure interfaces.d directory exists
-  file:
-    path: /etc/network/interfaces.d
-    state: directory
-  become: true
-
-- name: Ensure interfaces.d directory is sourced
-  lineinfile:
-    path: /etc/network/interfaces
-    line: source /etc/network/interfaces.d/*
-  become: true
-
-- name: Ensure all-in-one network dummy interface exists
-  become: true
-  copy:
-    content: |
-      auto {{ bridge_port_interface }}
-      iface {{ bridge_port_interface }}  inet manual
-    dest: /etc/network/interfaces.d/ifcfg-{{ bridge_port_interface }}
-
-- name: Ensure all-in-one network bridge interface exists
-  become: true
-  copy:
-    content: |
-      auto {{ bridge_interface }}
-      iface {{ bridge_interface }} inet static
-      address {{ bridge_ip }}
-      netmask {{ (bridge_ip ~ '/' ~ bridge_prefix) | ipaddr('netmask') }}
-      bridge_ports {{ bridge_port_interface }}
-    dest: /etc/network/interfaces.d/ifcfg-{{ bridge_interface }}
-
-- name: Ensure all-in-one network bridge interfaces are up
-  become: true
-  command: "{{ item }}"
-  with_items:
-    - "ifup {{ bridge_interface }}"
-    - "ifup {{ bridge_port_interface }}"
diff --git a/roles/kayobe-network-bootstrap/tasks/RedHat.yml b/roles/kayobe-network-bootstrap/tasks/RedHat.yml
deleted file mode 100644
index 125a62f979e77cbd997c349776246aba410e2ba5..0000000000000000000000000000000000000000
--- a/roles/kayobe-network-bootstrap/tasks/RedHat.yml
+++ /dev/null
@@ -1,14 +0,0 @@
----
-- name: Ensure all-in-one network bridge interface exists (RedHat)
-  command: "{{ item }}"
-  become: true
-  with_items:
-    - "ip l set {{ bridge_interface }} up"
-    - "ip a add {{ bridge_ip }}/{{ bridge_prefix }} dev {{ bridge_interface }}"
-    # NOTE(mgoddard): CentOS 8 removes interfaces from their bridge during
-    # ifdown, and removes the bridge if there are no interfaces left. When
-    # Kayobe bounces veth links plugged into the bridge, it causes the
-    # bridge which has the IP we are using for SSH to be removed. Use a
-    # dummy interface.
-    - "ip l set {{ bridge_port_interface }} up"
-    - "ip l set {{ bridge_port_interface }} master {{ bridge_interface }}"
diff --git a/roles/kayobe-network-bootstrap/tasks/main.yml b/roles/kayobe-network-bootstrap/tasks/main.yml
index e7ab9c59b9603a5fd0d1d48be20d82fc840c2516..e50433f4e69cfe6f25382f5831fb9688ab3df81c 100644
--- a/roles/kayobe-network-bootstrap/tasks/main.yml
+++ b/roles/kayobe-network-bootstrap/tasks/main.yml
@@ -6,4 +6,19 @@
     - "ip l add {{ bridge_interface }} type bridge"
     - "ip l add {{ bridge_port_interface }} type dummy"
 
-- include_tasks: "{{ ansible_os_family }}.yml"
+- name: Ensure all-in-one network bridge interface exists
+  vars:
+    bridge_cidr: "{{ bridge_ip }}/{{ bridge_prefix }}"
+    bridge_broadcast: "{{ bridge_cidr | ipaddr('broadcast') }}"
+  command: "{{ item }}"
+  become: true
+  with_items:
+    - "ip l set {{ bridge_interface }} up"
+    - "ip a add {{ bridge_cidr }} brd {{ bridge_broadcast }} dev {{ bridge_interface }}"
+    # NOTE(mgoddard): CentOS 8 removes interfaces from their bridge during
+    # ifdown, and removes the bridge if there are no interfaces left. When
+    # Kayobe bounces veth links plugged into the bridge, it causes the
+    # bridge which has the IP we are using for SSH to be removed. Use a
+    # dummy interface.
+    - "ip l set {{ bridge_port_interface }} up"
+    - "ip l set {{ bridge_port_interface }} master {{ bridge_interface }}"