diff --git a/ansible/inventory/group_vars/all/controllers b/ansible/inventory/group_vars/all/controllers
index ffae88c7f3a64c7cbeb26f10715efb17b6351175..f25cb4b2d9a87765bbe076c1969eb450e6562a34 100644
--- a/ansible/inventory/group_vars/all/controllers
+++ b/ansible/inventory/group_vars/all/controllers
@@ -6,6 +6,25 @@
 # to setup the Kayobe user account. Default is {{ os_distribution }}.
 controller_bootstrap_user: "{{ os_distribution }}"
 
+###############################################################################
+# Controller groups.
+
+# Ansible inventory group in which Ironic conductor services are deployed.
+# Default is 'controllers'.
+controller_ironic_conductor_group: controllers
+
+# Ansible inventory group in which Ironic inspector services are deployed.
+# Default is 'controllers'.
+controller_ironic_inspector_group: controllers
+
+# Ansible inventory group in which control plane load balancer services are
+# deployed. Default is 'network'.
+controller_loadbalancer_group: network
+
+# Ansible inventory group in which network data plane services are deployed.
+# Default is 'network'.
+controller_network_group: network
+
 ###############################################################################
 # Controller network interface configuration.
 
diff --git a/ansible/inventory/group_vars/all/neutron b/ansible/inventory/group_vars/all/neutron
index ce255f815a5ff042056ccf1f4d65a2bcf5a31a7d..cf1c479448ab7af333cf3308605dd3fd35fe4526 100644
--- a/ansible/inventory/group_vars/all/neutron
+++ b/ansible/inventory/group_vars/all/neutron
@@ -57,7 +57,7 @@ kolla_neutron_ml2_generic_switch_hosts: []
 # These hosts will be matched against the description fields in the
 # switch_interface_config variable for each switch to determine which
 # interfaces should be configured.
-kolla_neutron_ml2_generic_switch_trunk_port_hosts: "{{ groups['network'] }}"
+kolla_neutron_ml2_generic_switch_trunk_port_hosts: "{{ groups[controller_network_group] }}"
 
 # Dict containing additional configuration for switches managed by the
 # genericswitch ML2 mechanism driver. For per-switch configuration of switches
diff --git a/ansible/kolla-ansible.yml b/ansible/kolla-ansible.yml
index 5113dfe0b7271312b7a23b6e96bc9f83d86ec985..a31ec64d4cc0084324defd6a4201d43504fe276f 100644
--- a/ansible/kolla-ansible.yml
+++ b/ansible/kolla-ansible.yml
@@ -65,7 +65,7 @@
           ({{ item.description }}) is invalid. Value:
           "{{ hostvars[inventory_hostname][item.var_name] | default('<undefined>') }}".
       when:
-        - groups['network'] | length > 0
+        - groups[controller_loadbalancer_group] | length > 0
         - item.required | bool
       with_items:
         - var_name: "kolla_internal_vip_address"
@@ -112,6 +112,7 @@
         kolla_globals_paths_static:
           - "{{ kayobe_config_path }}"
         kolla_globals_paths_extra: "{{ kolla_globals_paths_static + kayobe_env_search_paths }}"
+        kolla_ironic_inspector_host: "{{ groups[controller_ironic_inspector_group][0] if groups[controller_ironic_inspector_group] | length > 0 else '' }}"
 
 - name: Generate Kolla Ansible host vars for the seed host
   hosts: seed
@@ -154,7 +155,7 @@
       vars:
         require_ironic_networks: >-
           {{ kolla_enable_ironic | bool and
-             inventory_hostname in groups['controllers'] }}
+             inventory_hostname in groups[controller_ironic_conductor_group] }}
         ironic_networks:
           - network: "{{ provision_wl_net_name }}"
             required: "{{ require_ironic_networks }}"
@@ -162,7 +163,7 @@
             required: "{{ require_ironic_networks }}"
         require_provider_networks: >-
           {{ kolla_enable_neutron | bool and
-             (inventory_hostname in groups['network'] or
+             (inventory_hostname in groups[controller_network_group] or
               (kolla_enable_neutron_provider_networks | bool and inventory_hostname in groups['compute'])) }}
         # This expression generates a list containing an item for each network
         # in external_net_names, in the format required by the
@@ -201,11 +202,11 @@
           - var_name: "kolla_provision_interface"
             description: "Bare metal provisioning network"
             network: "{{ provision_wl_net_name }}"
-            required: "{{ kolla_enable_ironic | bool and inventory_hostname in groups['controllers'] }}"
+            required: "{{ require_ironic_networks }}"
           - var_name: "kolla_inspector_dnsmasq_interface"
             description: "Bare metal introspection network"
             network: "{{ inspection_net_name }}"
-            required: "{{ kolla_enable_ironic | bool and inventory_hostname in groups['controllers'] }}"
+            required: "{{ require_ironic_networks }}"
           - var_name: "kolla_dns_interface"
             description: "DNS network"
             network: "{{ public_net_name }}"
@@ -217,7 +218,7 @@
           - var_name: "kolla_external_vip_interface"
             description: "External network"
             network: "{{ public_net_name }}"
-            required: "{{ inventory_hostname in groups['network'] }}"
+            required: "{{ inventory_hostname in groups[controller_loadbalancer_group] }}"
         external_networks: "{{ ironic_networks + provider_networks }}"
 
     - import_role:
diff --git a/ansible/roles/kolla-ansible/defaults/main.yml b/ansible/roles/kolla-ansible/defaults/main.yml
index e5ce22ff9b43f77c3b76e774b1a7602ff2b3c360..a4044aa536015c0c1211e811a3d6f9545bc43cc6 100644
--- a/ansible/roles/kolla-ansible/defaults/main.yml
+++ b/ansible/roles/kolla-ansible/defaults/main.yml
@@ -237,6 +237,14 @@ kolla_openstack_logging_debug:
 #kolla_enable_telegraf:
 #kolla_enable_watcher:
 
+#######################
+# Ironic options
+#######################
+
+# Which host to use to deploy the ironic-inspector services for ironic. By
+# default this is none and all hosts in the controllers group are used instead.
+kolla_ironic_inspector_host:
+
 #######################
 # Nova options
 #######################
diff --git a/ansible/roles/kolla-ansible/templates/overcloud-services.j2 b/ansible/roles/kolla-ansible/templates/overcloud-services.j2
index bef54bb09f1e247e16dbaf01faa5a549f882505f..b34c1e482048f76ec3ee2d6685d995dd5b58bff2 100644
--- a/ansible/roles/kolla-ansible/templates/overcloud-services.j2
+++ b/ansible/roles/kolla-ansible/templates/overcloud-services.j2
@@ -213,15 +213,12 @@ ironic
 [ironic-conductor:children]
 ironic
 
-#[ironic-inspector:children]
-#ironic
-
+{% if kolla_ironic_inspector_host %}
 [ironic-inspector]
-# FIXME: Ideally we wouldn't reference controllers in here directly, but only
-# one inspector service should exist, and groups can't be indexed in an
-# inventory (e.g. ironic[0]).
-{% if groups.get('controllers', []) | length > 0 %}
-{{ groups['controllers'][0] }}
+{{ kolla_ironic_inspector_host }}
+{% else %}
+[ironic-inspector:children]
+ironic
 {% endif %}
 
 [ironic-tftp:children]
diff --git a/doc/source/control-plane-service-placement.rst b/doc/source/control-plane-service-placement.rst
index b7fe19039cbab0cf5c079717947e34bdbe63a555..07b160a0639aee3df908f135900d7f9af2bb912d 100644
--- a/doc/source/control-plane-service-placement.rst
+++ b/doc/source/control-plane-service-placement.rst
@@ -247,3 +247,37 @@ Next, we must configure kayobe to use this inventory template.
 
 Here we use the ``template`` lookup plugin to render the Jinja2-formatted
 inventory template.
+
+Fine-grained placement
+======================
+
+Kayobe has fairly coarse-grained default groups - ``controller``, ``compute``,
+etc, which work well in the majority of cases. Kolla Ansible allows much
+more fine-grained placement on a per-service basis, e.g.
+``ironic-conductor``. If the operator has taken advantage of this
+fine-grained placement, then it is possible that some of the assumptions
+in Kayobe may be incorrect. This is one downside of the split between
+Kayobe and Kolla Ansible.
+
+For example, Ironic conductor services may have been moved to a subset of the
+top level ``controllers`` group. In this case, we would not want the Ironic
+networks to be mapped to all hosts in the controllers group - only those
+running Ironic conductor services. The same argument can be made if the
+loadbalancer services (HAProxy & keepalived) or Neutron dataplane services
+(e.g. L3 & DHCP agents) have been separated from the top level ``network``
+group.
+
+In these cases, the following variables may be used to tune placement:
+
+``controller_ironic_conductor_group``
+    Ansible inventory group in which Ironic conductor services are deployed.
+    Default is ``controllers``.
+``controller_ironic_inspector_group``
+    Ansible inventory group in which Ironic inspector services are deployed.
+    Default is ``controllers``.
+``controller_loadbalancer_group``
+    Ansible inventory group in which control plane load balancer services are
+    deployed. Default is ``network``.
+``controller_network_group``
+    Ansible inventory group in which network data plane services are deployed.
+    Default is ``network``.
diff --git a/etc/kayobe/controllers.yml b/etc/kayobe/controllers.yml
index 4780ec4440222ffc55f0fc2dc3dd03ecc11859cc..76b7bb00a5aac390bb1d1a9246f6b9f3a4e96e3d 100644
--- a/etc/kayobe/controllers.yml
+++ b/etc/kayobe/controllers.yml
@@ -6,6 +6,25 @@
 # to setup the Kayobe user account. Default is {{ os_distribution }}.
 #controller_bootstrap_user:
 
+###############################################################################
+# Controller groups.
+
+# Ansible inventory group in which Ironic conductor services are deployed.
+# Default is 'controllers'.
+#controller_ironic_conductor_group:
+
+# Ansible inventory group in which Ironic inspector services are deployed.
+# Default is 'controllers'.
+#controller_ironic_inspector_group:
+
+# Ansible inventory group in which control plane load balancer services are
+# deployed. Default is 'network'.
+#controller_loadbalancer_group:
+
+# Ansible inventory group in which network data plane services are deployed.
+# Default is 'network'.
+#controller_network_group:
+
 ###############################################################################
 # Controller network interface configuration.
 
diff --git a/releasenotes/notes/fine-grained-controller-groups-f2b82a8eea2a66c7.yaml b/releasenotes/notes/fine-grained-controller-groups-f2b82a8eea2a66c7.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..170eb5e71e074513fd8e80344993a77e25aa25f7
--- /dev/null
+++ b/releasenotes/notes/fine-grained-controller-groups-f2b82a8eea2a66c7.yaml
@@ -0,0 +1,10 @@
+---
+features:
+  - |
+    Adds the following variables to allow more fine-grained placement of
+    services:
+
+    * ``controller_ironic_conductor_group``
+    * ``controller_ironic_inspector_group``
+    * ``controller_loadbalancer_group``
+    * ``controller_network_group``