diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst
index 01be73b35cd51b4be1f0860589a24c23ad641fd2..1cab3b104459cf6dd384deba896a35f5c21d6fea 100644
--- a/doc/source/configuration/reference/network.rst
+++ b/doc/source/configuration/reference/network.rst
@@ -343,6 +343,9 @@ The following attributes are supported:
 
 ``interface``
     The name of the network interface attached to the network.
+``parent``
+    The name of the parent interface, when configuring a VLAN interface using
+    ``systemd-networkd`` syntax.
 ``bootproto``
     Boot protocol for the interface. Valid values are ``static`` and ``dhcp``.
     The default is ``static``. When set to ``dhcp``, an external DHCP server
@@ -473,8 +476,9 @@ Configuring VLAN Interfaces
 ---------------------------
 
 A VLAN interface may be configured by setting the ``interface`` attribute of a
-network to the name of the VLAN interface.  The interface name must be of the
-form ``<parent interface>.<VLAN ID>``.
+network to the name of the VLAN interface. The interface name must normally be
+of the form ``<parent interface>.<VLAN ID>`` to ensure compatibility with all
+supported host operating systems.
 
 To configure a network called ``example`` with a VLAN interface with a parent
 interface of ``eth2`` for VLAN ``123``:
@@ -491,6 +495,16 @@ To keep the configuration DRY, reference the network's ``vlan`` attribute:
 
    example_interface: "eth2.{{ example_vlan }}"
 
+Alternatively, when using Ubuntu as a host operating system, VLAN interfaces
+can be named arbitrarily using syntax supported by ``systemd-networkd``. In
+this case, a ``parent`` attribute must specify the underlying interface:
+
+.. code-block:: yaml
+   :caption: ``inventory/group_vars/<group>/network-interfaces``
+
+   example_interface: "myvlan{{ example_vlan }}"
+   example_parent: "eth2"
+
 Ethernet interfaces, bridges, and bond master interfaces may all be parents to
 a VLAN interface.
 
diff --git a/kayobe/plugins/action/kolla_ansible_host_vars.py b/kayobe/plugins/action/kolla_ansible_host_vars.py
index 4696063d8dd932b18e90e2fd3bde4d2d940cf37e..d03aac6aab02fe8a281003bf69ba417e4227520e 100644
--- a/kayobe/plugins/action/kolla_ansible_host_vars.py
+++ b/kayobe/plugins/action/kolla_ansible_host_vars.py
@@ -123,7 +123,11 @@ class ActionModule(ActionBase):
                 # tagged interface may be shared between these networks.
                 vlan = self._templar.template("{{ '%s' | net_vlan }}" %
                                               net_name)
-                if vlan and iface.endswith(".%s" % vlan):
+                parent = self._templar.template("{{ '%s' | net_parent }}" %
+                                                net_name)
+                if vlan and parent:
+                    iface = parent
+                elif vlan and iface.endswith(".%s" % vlan):
                     iface = iface.replace(".%s" % vlan, "")
                 return iface
             elif required:
diff --git a/kayobe/plugins/filter/networkd.py b/kayobe/plugins/filter/networkd.py
index f07c2eb4b189a33372f4504187de39a17e9f4a2d..2d231358182d7f7ee15f14084a51e73f693f8af0 100644
--- a/kayobe/plugins/filter/networkd.py
+++ b/kayobe/plugins/filter/networkd.py
@@ -612,7 +612,8 @@ def networkd_networks(context, names, inventory_hostname=None):
                                                      inventory_hostname)
         vlan = networks.net_vlan(context, name, inventory_hostname)
         mtu = networks.net_mtu(context, name, inventory_hostname)
-        parent = networks.get_vlan_parent(device, vlan)
+        parent = networks.get_vlan_parent(
+            context, name, device, vlan, inventory_hostname)
         vlan_interfaces = interface_to_vlans.setdefault(parent, [])
         vlan_interfaces.append({"device": device, "mtu": mtu})
 
diff --git a/kayobe/plugins/filter/networks.py b/kayobe/plugins/filter/networks.py
index 29c6018e4f8bf1932c24b523a9f37c08a6c69b8f..ed9d23b964469648e7df1787996d0e01bfdcdcc4 100644
--- a/kayobe/plugins/filter/networks.py
+++ b/kayobe/plugins/filter/networks.py
@@ -106,7 +106,8 @@ def get_ovs_veths(context, names, inventory_hostname):
         # tagged interface may be shared between these networks.
         vlan = net_vlan(context, name, inventory_hostname)
         if vlan:
-            parent_or_device = get_vlan_parent(device, vlan)
+            parent_or_device = get_vlan_parent(
+                context, name, device, vlan, inventory_hostname)
         else:
             parent_or_device = device
         if parent_or_device in bridge_interfaces:
@@ -131,14 +132,21 @@ def get_ovs_veths(context, names, inventory_hostname):
     ]
 
 
-def get_vlan_parent(device, vlan):
+def get_vlan_parent(context, name, device, vlan, inventory_hostname):
     """Return the parent interface of a VLAN subinterface.
 
+    :param context: a Jinja2 Context object.
+    :param name: name of the network.
     :param device: VLAN interface name.
     :param vlan: VLAN ID.
+    :param inventory_hostname: Ansible inventory hostname.
     :returns: parent interface name.
+    :raises: ansible.errors.AnsibleFilterError
     """
-    return re.sub(r'\.{}$'.format(vlan), '', device)
+    parent = net_parent(context, name, inventory_hostname)
+    if not parent:
+        parent = re.sub(r'\.{}$'.format(vlan), '', device)
+    return parent
 
 
 @jinja2.pass_context
@@ -190,6 +198,11 @@ def net_mask(context, name, inventory_hostname=None):
     return str(netaddr.IPNetwork(cidr).netmask) if cidr is not None else None
 
 
+@jinja2.pass_context
+def net_parent(context, name, inventory_hostname=None):
+    return net_attr(context, name, 'parent', inventory_hostname)
+
+
 @jinja2.pass_context
 def net_prefix(context, name, inventory_hostname=None):
     cidr = net_cidr(context, name, inventory_hostname)
@@ -545,10 +558,15 @@ def net_is_vlan(context, name, inventory_hostname=None):
 
 @jinja2.pass_context
 def net_is_vlan_interface(context, name, inventory_hostname=None):
-    device = get_and_validate_interface(context, name, inventory_hostname)
-    # Use a heuristic to match conventional VLAN names, ending with a
-    # period and a numerical extension to an interface name
-    return re.match(r"^[a-zA-Z0-9_\-]+\.[1-9][\d]{0,3}$", device)
+    parent = net_parent(context, name, inventory_hostname)
+    vlan = net_vlan(context, name, inventory_hostname)
+    if parent and vlan:
+        return True
+    else:
+        device = get_and_validate_interface(context, name, inventory_hostname)
+        # Use a heuristic to match conventional VLAN names, ending with a
+        # period and a numerical extension to an interface name
+        return re.match(r"^[a-zA-Z0-9_\-]+\.[1-9][\d]{0,3}$", device)
 
 
 @jinja2.pass_context
@@ -600,7 +618,10 @@ def net_configdrive_network_device(context, name, inventory_hostname=None):
     bootproto = net_bootproto(context, name, inventory_hostname)
     mtu = net_mtu(context, name, inventory_hostname)
     vlan = net_vlan(context, name, inventory_hostname)
-    if vlan and '.' in device:
+    parent = net_parent(context, name, inventory_hostname)
+    if vlan and parent:
+        backend = parent
+    elif vlan and '.' in device:
         backend = [device.split('.')[0]]
     else:
         backend = None
@@ -678,6 +699,7 @@ def get_filters():
         'net_fqdn': _make_attr_filter('fqdn'),
         'net_ip': net_ip,
         'net_interface': net_interface,
+        'net_parent': net_parent,
         'net_no_ip': net_no_ip,
         'net_cidr': net_cidr,
         'net_mask': net_mask,
diff --git a/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py b/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py
index 7be386d5ab0d1ef17fdea94a40995516e87d77d1..ec0a60d0fb4b7a943aa412f87865d449a43c8c08 100644
--- a/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py
+++ b/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py
@@ -25,6 +25,11 @@ def _net_interface(context, name):
     return context.get(name + '_interface')
 
 
+@jinja2.pass_context
+def _net_parent(context, name):
+    return context.get(name + '_parent')
+
+
 @jinja2.pass_context
 def _net_vlan(context, name):
     return context.get(name + '_vlan')
@@ -42,6 +47,7 @@ class FakeTemplar(object):
         self.variables = variables
         self.env = jinja2.Environment()
         self.env.filters['net_interface'] = _net_interface
+        self.env.filters['net_parent'] = _net_parent
         self.env.filters['net_vlan'] = _net_vlan
         self.env.filters['net_select_bridges'] = _net_select_bridges
 
diff --git a/kayobe/tests/unit/plugins/filter/test_networkd.py b/kayobe/tests/unit/plugins/filter/test_networkd.py
index ae1936056a10ce35e0918a363fc5fb7879108b0d..45e2e81a2f48678c3d2e7a9023b7eb1a46ea1917 100644
--- a/kayobe/tests/unit/plugins/filter/test_networkd.py
+++ b/kayobe/tests/unit/plugins/filter/test_networkd.py
@@ -42,6 +42,15 @@ class BaseNetworkdTest(unittest.TestCase):
         # net4: bond on bond0 with members eth0 and eth1.
         "net4_interface": "bond0",
         "net4_bond_slaves": ["eth0", "eth1"],
+        # net5: VLAN on vlan.5 with VLAN 5 on interface eth0.
+        "net5_interface": "vlan.5",
+        "net5_parent": "eth0",
+        "net5_vlan": 5,
+        # net6: VLAN on vlan6 with VLAN 6 on interface eth0.
+        "net6_interface": "vlan6",
+        "net6_parent": "eth0",
+        "net6_vlan": 6,
+        # NOTE(priteau): net7 is used in test_veth_on_vlan
         # Prefix for networkd config file names.
         "networkd_prefix": "50-kayobe-",
         # Veth pair patch link prefix and suffix.
@@ -132,6 +141,52 @@ class TestNetworkdNetDevs(BaseNetworkdTest):
         self.assertRaises(errors.AnsibleFilterError,
                           networkd.networkd_netdevs, self.context, ["net2"])
 
+    def test_vlan_with_parent(self):
+        devs = networkd.networkd_netdevs(self.context,
+                                         ["net1", "net2", "net5", "net6"])
+        expected = {
+            "50-kayobe-eth0.2": [
+                {
+                    "NetDev": [
+                        {"Name": "eth0.2"},
+                        {"Kind": "vlan"},
+                    ]
+                },
+                {
+                    "VLAN": [
+                        {"Id": 2},
+                    ]
+                },
+            ],
+            "50-kayobe-vlan.5": [
+                {
+                    "NetDev": [
+                        {"Name": "vlan.5"},
+                        {"Kind": "vlan"},
+                    ]
+                },
+                {
+                    "VLAN": [
+                        {"Id": 5},
+                    ]
+                },
+            ],
+            "50-kayobe-vlan6": [
+                {
+                    "NetDev": [
+                        {"Name": "vlan6"},
+                        {"Kind": "vlan"},
+                    ]
+                },
+                {
+                    "VLAN": [
+                        {"Id": 6},
+                    ]
+                },
+            ]
+        }
+        self.assertEqual(expected, devs)
+
     def test_bridge(self):
         devs = networkd.networkd_netdevs(self.context, ["net3"])
         expected = {
@@ -437,7 +492,8 @@ class TestNetworkdNetworks(BaseNetworkdTest):
         self.assertEqual(expected, nets)
 
     def test_vlan_with_parent(self):
-        nets = networkd.networkd_networks(self.context, ["net1", "net2"])
+        nets = networkd.networkd_networks(self.context,
+                                          ["net1", "net2", "net5", "net6"])
         expected = {
             "50-kayobe-eth0": [
                 {
@@ -449,6 +505,8 @@ class TestNetworkdNetworks(BaseNetworkdTest):
                     "Network": [
                         {"Address": "1.2.3.4/24"},
                         {"VLAN": "eth0.2"},
+                        {"VLAN": "vlan.5"},
+                        {"VLAN": "vlan6"},
                     ]
                 },
             ],
@@ -458,6 +516,20 @@ class TestNetworkdNetworks(BaseNetworkdTest):
                         {"Name": "eth0.2"}
                     ]
                 },
+            ],
+            "50-kayobe-vlan.5": [
+                {
+                    "Match": [
+                        {"Name": "vlan.5"}
+                    ]
+                },
+            ],
+            "50-kayobe-vlan6": [
+                {
+                    "Match": [
+                        {"Name": "vlan6"}
+                    ]
+                },
             ]
         }
         self.assertEqual(expected, nets)
@@ -946,11 +1018,11 @@ class TestNetworkdNetworks(BaseNetworkdTest):
         # 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",
+            "provision_wl_net_name": "net7",
             "net3_bridge_ports": [],
-            "net5_interface": "br0.42",
-            "net5_vlan": 42})
-        nets = networkd.networkd_networks(self.context, ["net3", "net5"])
+            "net7_interface": "br0.42",
+            "net7_vlan": 42})
+        nets = networkd.networkd_networks(self.context, ["net3", "net7"])
         expected = {
             "50-kayobe-br0": [
                 {
diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
index 29becbce2a3bf803fa873d067343bfd4e6e63251..a88e0ef6ea122fa0d1d0996b5e65ab1fad86030f 100644
--- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
+++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2
@@ -19,6 +19,9 @@ controller_extra_network_interfaces:
   - test_net_bond
   - test_net_bond_vlan
   - test_net_bridge_noip
+{% if ansible_os_family == "Debian" %}
+  - test_net_systemd_vlan
+{% endif %}
 
 # Custom IP routing tables.
 network_route_tables:
@@ -79,6 +82,15 @@ test_net_bridge_noip_interface: br1
 test_net_bridge_noip_bridge_ports: [dummy7]
 test_net_bridge_noip_no_ip: true
 
+{% if ansible_os_family == "Debian" %}
+# vlan45: VLAN interface of bond0 using systemd-networkd style
+test_net_systemd_vlan_cidr: 192.168.41.0/24
+test_net_systemd_vlan_interface: "vlan{% raw %}{{ test_net_systemd_vlan_vlan }}{% endraw %}"
+test_net_systemd_vlan_parent: "{% raw %}{{ test_net_bond_interface }}{% endraw %}"
+test_net_systemd_vlan_vlan: 45
+test_net_systemd_vlan_zone: public
+{% endif %}
+
 # Define a software RAID device consisting of two loopback devices.
 controller_mdadm_arrays:
   - name: md0
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 59496424ba28ad661634c0fec25f8679e3d25cbd..896adae1490310cc390ac6b002bb70f090e5c98b 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
@@ -102,6 +102,14 @@ def test_network_bridge_no_ip(host):
     assert not '192.168.40.1' in interface.addresses
 
 
+@pytest.mark.skipif(not _is_apt(),
+                    reason="systemd-networkd VLANs only supported on Ubuntu")
+def test_network_systemd_vlan(host):
+    interface = host.interface('vlan45')
+    assert interface.exists
+    assert '192.168.41.1' in interface.addresses
+
+
 def test_additional_user_account(host):
       user = host.user("kayobe-test-user")
       assert user.name == "kayobe-test-user"
diff --git a/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml b/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..7b53b551477515d0465e3e5dc1af31ff9ebdeda9
--- /dev/null
+++ b/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml
@@ -0,0 +1,6 @@
+---
+features:
+  - |
+    Adds support for configuring arbitrarily named VLAN interfaces using
+    ``systemd-networkd``. See `story 2010266
+    <https://storyboard.openstack.org/#!/story/2010266>`__ for details.