diff --git a/ansible/firewall.yml b/ansible/firewall.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d099aaa7e2324c8357a0fd3721122cb8fd786fcd
--- /dev/null
+++ b/ansible/firewall.yml
@@ -0,0 +1,12 @@
+---
+- name: Ensure firewall is configured
+  hosts: seed-hypervisor:seed:overcloud
+  tags:
+    - config
+    - firewall
+  tasks:
+    - name: Configure the firewall
+      include_role:
+        name: "firewall-{{ ansible_facts.os_family | lower }}"
+      when:
+        - ansible_facts.os_family == 'RedHat'
diff --git a/ansible/group_vars/all/compute b/ansible/group_vars/all/compute
index 61bbe91f1bdc4c9f38af95d5fbc117b13e86c715..92d32b336d2bd6e3c4cc176982ea7632ee0458fe 100644
--- a/ansible/group_vars/all/compute
+++ b/ansible/group_vars/all/compute
@@ -133,3 +133,24 @@ compute_sysctl_parameters: {}
 # List of users to create. This should be in a format accepted by the
 # singleplatform-eng.users role.
 compute_users: "{{ users_default }}"
+
+###############################################################################
+# Compute node firewalld configuration.
+
+# Whether to install and enable firewalld.
+compute_firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+compute_firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+compute_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+compute_firewalld_rules: []
diff --git a/ansible/group_vars/all/controllers b/ansible/group_vars/all/controllers
index 0c09024fad5f442a4ed269e80dc71ce024788871..f0322b8856922332b0c6a10defbe7a35b4132ce1 100644
--- a/ansible/group_vars/all/controllers
+++ b/ansible/group_vars/all/controllers
@@ -155,3 +155,24 @@ controller_sysctl_parameters: {}
 # List of users to create. This should be in a format accepted by the
 # singleplatform-eng.users role.
 controller_users: "{{ users_default }}"
+
+###############################################################################
+# Controller node firewalld configuration.
+
+# Whether to install and enable firewalld.
+controller_firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+controller_firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+controller_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+controller_firewalld_rules: []
diff --git a/ansible/group_vars/all/monitoring b/ansible/group_vars/all/monitoring
index 2a9a5c0e5fdd3587a324caf3986f07acec8522d7..e4315cbcd3b2e2e1fb76095356a7d5aabb51f97c 100644
--- a/ansible/group_vars/all/monitoring
+++ b/ansible/group_vars/all/monitoring
@@ -94,3 +94,24 @@ monitoring_sysctl_parameters: "{{ controller_sysctl_parameters }}"
 # List of users to create. This should be in a format accepted by the
 # singleplatform-eng.users role.
 monitoring_users: "{{ controller_users }}"
+
+###############################################################################
+# Monitoring node firewalld configuration.
+
+# Whether to install and enable firewalld.
+monitoring_firewalld_enabled: "{{ controller_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+monitoring_firewalld_zones: "{{ controller_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+monitoring_firewalld_default_zone: "{{ controller_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+monitoring_firewalld_rules: "{{ controller_firewalld_rules }}"
diff --git a/ansible/group_vars/all/seed b/ansible/group_vars/all/seed
index decdd2a52a6ae3752bde4f805558e71e969df608..cde5721106363438e82529712c47de99192dd7d3 100644
--- a/ansible/group_vars/all/seed
+++ b/ansible/group_vars/all/seed
@@ -113,3 +113,24 @@ seed_users: "{{ users_default }}"
 #     post: "{{ kayobe_env_config_path }}/containers/squid/post.yml"
 #
 seed_containers: {}
+
+###############################################################################
+# Seed node firewalld configuration.
+
+# Whether to install and enable firewalld.
+seed_firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+seed_firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+seed_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+seed_firewalld_rules: []
diff --git a/ansible/group_vars/all/seed-hypervisor b/ansible/group_vars/all/seed-hypervisor
index 9ee93d1187a27a6b2cb8e9b8f7479eb0000d97f1..711b4cf768d9065320b9b6a608a97613d340623d 100644
--- a/ansible/group_vars/all/seed-hypervisor
+++ b/ansible/group_vars/all/seed-hypervisor
@@ -128,3 +128,24 @@ seed_hypervisor_sysctl_parameters: {}
 # List of users to create. This should be in a format accepted by the
 # singleplatform-eng.users role.
 seed_hypervisor_users: "{{ users_default }}"
+
+###############################################################################
+# Seed hypervisor node firewalld configuration.
+
+# Whether to install and enable firewalld.
+seed_hypervisor_firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+seed_hypervisor_firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+seed_hypervisor_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+seed_hypervisor_firewalld_rules: []
diff --git a/ansible/group_vars/all/storage b/ansible/group_vars/all/storage
index b474e30910ceb24796c57121a3021279a5d7b6bc..eabaa41fa5c5e2062aca71ffa5ca4a97e77352db 100644
--- a/ansible/group_vars/all/storage
+++ b/ansible/group_vars/all/storage
@@ -145,3 +145,24 @@ storage_sysctl_parameters: {}
 # List of users to create. This should be in a format accepted by the
 # singleplatform-eng.users role.
 storage_users: "{{ users_default }}"
+
+###############################################################################
+# Storage node firewalld configuration.
+
+# Whether to install and enable firewalld.
+storage_firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+storage_firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+storage_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+storage_firewalld_rules: []
diff --git a/ansible/group_vars/compute/firewall b/ansible/group_vars/compute/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..f1d30d51acce9d5743c8909ad86ac6dfeca7a32d
--- /dev/null
+++ b/ansible/group_vars/compute/firewall
@@ -0,0 +1,21 @@
+---
+###############################################################################
+# Compute node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: "{{ compute_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: "{{ compute_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: "{{ compute_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: "{{ compute_firewalld_rules }}"
diff --git a/ansible/group_vars/controllers/firewall b/ansible/group_vars/controllers/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..dce2e0e70b06ef3b17fae7a0062533faa8d97a73
--- /dev/null
+++ b/ansible/group_vars/controllers/firewall
@@ -0,0 +1,21 @@
+---
+###############################################################################
+# Controller node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: "{{ controller_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: "{{ controller_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: "{{ controller_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: "{{ controller_firewalld_rules }}"
diff --git a/ansible/group_vars/monitoring/firewall b/ansible/group_vars/monitoring/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..a1b151527f24a83d79e53066be88767591bb3615
--- /dev/null
+++ b/ansible/group_vars/monitoring/firewall
@@ -0,0 +1,33 @@
+---
+###############################################################################
+# Monitoring node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: >-
+  {{ controller_firewalld_enabled
+     if inventory_hostname in groups['controllers'] else
+     monitoring_firewalld_enabled }}
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: >
+  {{ controller_firewalld_zones
+     if inventory_hostname in groups['controllers'] else
+     monitoring_firewalld_zones }}
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: >-
+  {{ controller_firewalld_default_zone
+     if inventory_hostname in groups['controllers'] else
+     monitoring_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: >
+  {{ controller_firewalld_rules
+     if inventory_hostname in groups['controllers'] else
+     monitoring_firewalld_rules }}"
diff --git a/ansible/group_vars/seed-hypervisor/firewall b/ansible/group_vars/seed-hypervisor/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..9de277119ce856dbe75093db5ff4fb20081e8228
--- /dev/null
+++ b/ansible/group_vars/seed-hypervisor/firewall
@@ -0,0 +1,21 @@
+---
+###############################################################################
+# Seed Hypervisor node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: "{{ seed_hypervisor_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: "{{ seed_hypervisor_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: "{{ seed_hypervisor_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: "{{ seed_hypervisor_firewalld_rules }}"
diff --git a/ansible/group_vars/seed/firewall b/ansible/group_vars/seed/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..80cd15a27b7660a0afdbedbeb3302d5ea7a9bcee
--- /dev/null
+++ b/ansible/group_vars/seed/firewall
@@ -0,0 +1,21 @@
+---
+###############################################################################
+# Seed node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: "{{ seed_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: "{{ seed_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: "{{ seed_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: "{{ seed_firewalld_rules }}"
diff --git a/ansible/group_vars/storage/firewall b/ansible/group_vars/storage/firewall
new file mode 100644
index 0000000000000000000000000000000000000000..a3721fef3b84f2fa2205d69ed1916b440946cc0c
--- /dev/null
+++ b/ansible/group_vars/storage/firewall
@@ -0,0 +1,21 @@
+---
+###############################################################################
+# Storage node firewalld configuration.
+
+# Whether to install and enable firewalld.
+firewalld_enabled: "{{ storage_firewalld_enabled }}"
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: "{{ storage_firewalld_zones }}"
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone: "{{ storage_firewalld_default_zone }}"
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: "{{ storage_firewalld_rules }}"
diff --git a/ansible/roles/firewall-redhat/defaults/main.yml b/ansible/roles/firewall-redhat/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8ca780000a781c5cbd34dd3911763aa5d67d63dd
--- /dev/null
+++ b/ansible/roles/firewall-redhat/defaults/main.yml
@@ -0,0 +1,18 @@
+---
+# Whether to install and enable firewalld.
+firewalld_enabled: false
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+firewalld_zones: []
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+firewalld_rules: []
diff --git a/ansible/roles/firewall-redhat/handlers/main.yml b/ansible/roles/firewall-redhat/handlers/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a29336dce07ea0a2757147142f172cb16c3b411c
--- /dev/null
+++ b/ansible/roles/firewall-redhat/handlers/main.yml
@@ -0,0 +1,10 @@
+---
+- name: Restart firewalld
+  service:
+    name: firewalld
+    state: restarted
+  become: true
+
+- name: Check connectivity after firewalld restart
+  ping:
+  listen: Restart firewalld
diff --git a/ansible/roles/firewall-redhat/tasks/disabled.yml b/ansible/roles/firewall-redhat/tasks/disabled.yml
new file mode 100644
index 0000000000000000000000000000000000000000..af642b5c9a0f7e4c990eab7c3572054bee79b987
--- /dev/null
+++ b/ansible/roles/firewall-redhat/tasks/disabled.yml
@@ -0,0 +1,18 @@
+---
+- name: Ensure firewalld service is stopped and disabled
+  service:
+    name: firewalld
+    enabled: false
+    state: stopped
+  become: true
+  register: firewalld_result
+  failed_when:
+    - firewalld_result is failed
+    # Ugh, Ansible's service module doesn't handle uninstalled services.
+    - "'Could not find the requested service' not in firewalld_result.msg"
+
+- name: Ensure firewalld package is uninstalled
+  package:
+    name: firewalld
+    state: absent
+  become: true
diff --git a/ansible/roles/firewall-redhat/tasks/enabled.yml b/ansible/roles/firewall-redhat/tasks/enabled.yml
new file mode 100644
index 0000000000000000000000000000000000000000..048645169735ea3ebef9c4c19c1b05b141f327df
--- /dev/null
+++ b/ansible/roles/firewall-redhat/tasks/enabled.yml
@@ -0,0 +1,71 @@
+---
+- name: Ensure firewalld package is installed
+  package:
+    name: firewalld
+  become: true
+
+- name: Ensure firewalld service is enabled
+  service:
+    name: firewalld
+    enabled: true
+    # FIXME: should be possible to configure firewalld offline, but it fails to
+    # apply config.
+    state: started
+  become: true
+
+- block:
+    - name: Get firewalld current default zone
+      command:
+        cmd: "firewall-offline-cmd --get-default-zone"
+      changed_when: false
+      register: current_default_zone
+
+    - name: Set firewalld default zone
+      command: "firewall-offline-cmd --set-default-zone {{ firewalld_default_zone }}"
+      when: current_default_zone.stdout != firewalld_default_zone
+      notify: Restart firewalld
+  become: true
+  when:
+    - firewalld_default_zone is not none
+    - firewalld_default_zone | length > 0
+
+- name: Ensure firewalld zones exist
+  firewalld:
+    offline: true
+    permanent: true
+    state: "{{ item.state | default('present') }}"
+    zone: "{{ item.zone }}"
+  become: true
+  loop: "{{ firewalld_zones }}"
+
+- name: Set firewalld zones for network interfaces
+  firewalld:
+    interface: "{{ item | net_interface }}"
+    offline: true
+    permanent: true
+    state: enabled
+    zone: "{{ item | net_zone }}"
+  become: true
+  loop: "{{ network_interfaces }}"
+  when: item | net_zone
+  notify: Restart firewalld
+
+- name: Ensure firewalld rules are applied
+  firewalld:
+    icmp_block: "{{ item.icmp_block | default(omit) }}"
+    icmp_block_inversion: "{{ item.icmp_block_inversion | default(omit) }}"
+    immediate: "{{ item.immediate | default(omit) }}"
+    interface: "{{ item.interface | default(omit) }}"
+    masquerade: "{{ item.masquerade | default(omit) }}"
+    offline: "{{ item.offline | default(true) }}"
+    permanent: "{{ item.permanent | default(true) }}"
+    port: "{{ item.port | default(omit) }}"
+    rich_rule: "{{ item.rich_rule | default(omit) }}"
+    service: "{{ item.service | default(omit) }}"
+    source: "{{ item.source | default(omit) }}"
+    state: "{{ item.state | default('enabled') }}"
+    timeout: "{{ item.timeout | default(omit) }}"
+    zone: "{{ item.zone | default(omit) }}"
+  become: true
+  loop: "{{ firewalld_rules }}"
+  notify: Restart firewalld
diff --git a/ansible/roles/firewall-redhat/tasks/main.yml b/ansible/roles/firewall-redhat/tasks/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..108ffc8ce6c167b8da8769db98df7cae5be8f032
--- /dev/null
+++ b/ansible/roles/firewall-redhat/tasks/main.yml
@@ -0,0 +1,3 @@
+---
+- name: Include tasks
+  include_tasks: "{{ 'enabled' if firewalld_enabled | bool else 'disabled' }}.yml"
diff --git a/ansible/roles/kolla-ansible/templates/globals.yml.j2 b/ansible/roles/kolla-ansible/templates/globals.yml.j2
index b549adc5bd02df4ce4ee30455d9402d3126dcfd4..84de6c1e033d3ee931cdfc9253394f84c8ee9044 100644
--- a/ansible/roles/kolla-ansible/templates/globals.yml.j2
+++ b/ansible/roles/kolla-ansible/templates/globals.yml.j2
@@ -565,6 +565,9 @@ kolla_group: "{{ kolla_ansible_group }}"
 virtualenv: {{ kolla_ansible_target_venv }}
 {% endif %}
 
+# Avoid disabling the firewall on CentOS, since we manage it in Kayobe.
+disable_firewall: "{% raw %}{{ ansible_facts.os_family == 'Debian' }}{% endraw %}"
+
 {% if kolla_extra_globals %}
 #######################
 # Extra configuration
diff --git a/doc/source/configuration/reference/hosts.rst b/doc/source/configuration/reference/hosts.rst
index 86c3c2652f9bfa37cdda5bb9a8bf0473299c381e..6ffd9655b0e534d3fc5effbf23da63981d3c5058 100644
--- a/doc/source/configuration/reference/hosts.rst
+++ b/doc/source/configuration/reference/hosts.rst
@@ -328,6 +328,98 @@ Network Configuration
 Configuration of host networking is covered in depth in
 :ref:`configuration-network`.
 
+Firewalld
+=========
+*tags:*
+  | ``firewall``
+
+.. note:: Firewalld is supported on CentOS systems only. Currently no
+          firewall is supported on Ubuntu.
+
+Firewalld can be used to provide a firewall on CentOS systems. Since the Xena
+release, Kayobe provides support for enabling or disabling firewalld, as well
+as defining zones and rules.
+
+The following variables can be used to set whether to enable firewalld:
+
+* ``seed_hypervisor_firewalld_enabled``
+* ``seed_firewalld_enabled``
+* ``compute_firewalld_enabled``
+* ``controller_firewalld_enabled``
+* ``monitoring_firewalld_enabled``
+* ``storage_firewalld_enabled``
+
+When firewalld is enabled, the following variables can be used to configure a
+list of zones to create. Each item is a dict containing a ``zone`` item:
+
+* ``seed_hypervisor_firewalld_zones``
+* ``seed_firewalld_zones``
+* ``compute_firewalld_zones``
+* ``controller_firewalld_zones``
+* ``monitoring_firewalld_zones``
+* ``storage_firewalld_zones``
+
+The following variables can be used to set a default zone. The default is
+unset, in which case the default zone will not be changed:
+
+* ``seed_hypervisor_firewalld_default_zone``
+* ``seed_firewalld_default_zone``
+* ``compute_firewalld_default_zone``
+* ``controller_firewalld_default_zone``
+* ``monitoring_firewalld_default_zone``
+* ``storage_firewalld_default_zone``
+
+The following variables can be used to set a list of rules to apply. Each item
+is a dict containing arguments to pass to the ``firewalld`` module. Arguments
+are omitted if not provided, with the following exceptions: ``offline``
+(default ``true``), ``permanent`` (default ``true``), ``state`` (default
+``enabled``):
+
+* ``seed_hypervisor_firewalld_rules``
+* ``seed_firewalld_rules``
+* ``compute_firewalld_rules``
+* ``controller_firewalld_rules``
+* ``monitoring_firewalld_rules``
+* ``storage_firewalld_rules``
+
+In the following example, firewalld is enabled on controllers. ``public`` and
+``internal`` zones are created, with their default rules disabled. TCP port
+8080 is open in the ``internal`` zone, and the ``http`` service is open in the
+``public`` zone:
+
+.. code-block:: yaml
+
+   controller_firewalld_enabled: true
+
+   controller_firewalld_zones:
+     - zone: public
+     - zone: internal
+
+   controller_firewalld_rules:
+     # Disable default rules in internal zone.
+     - service: dhcpv6-client
+       state: disabled
+       zone: internal
+     - service: samba-client
+       state: disabled
+       zone: internal
+     - service: ssh
+       state: disabled
+       zone: internal
+     # Disable default rules in public zone.
+     - service: dhcpv6-client
+       state: disabled
+       zone: public
+     - service: ssh
+       state: disabled
+       zone: public
+     # Enable TCP port 8080 in internal zone.
+     - port: 8080/tcp
+       zone: internal
+     # Enable the HTTP service in the public zone.
+     - service: http
+       zone: public
+
 Sysctls
 =======
 *tags:*
diff --git a/etc/kayobe/compute.yml b/etc/kayobe/compute.yml
index 59a68fa781f5d7697f30353a627130b341ac8306..af8d35a8fb42eb325940ec1da695e91f4138cc9c 100644
--- a/etc/kayobe/compute.yml
+++ b/etc/kayobe/compute.yml
@@ -115,6 +115,27 @@
 # singleplatform-eng.users role.
 #compute_users:
 
+###############################################################################
+# Compute node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#compute_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#compute_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#compute_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#compute_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/etc/kayobe/controllers.yml b/etc/kayobe/controllers.yml
index 6a4e45eeb588e89e44df1a3e46c6adffa7be3dde..62a1524aa0ab23756d756542b16661e87886e365 100644
--- a/etc/kayobe/controllers.yml
+++ b/etc/kayobe/controllers.yml
@@ -124,6 +124,27 @@
 # singleplatform-eng.users role.
 #controller_users:
 
+###############################################################################
+# Controller node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#controller_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#controller_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#controller_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#controller_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/etc/kayobe/monitoring.yml b/etc/kayobe/monitoring.yml
index e28e5ccf1c72f5637d4a543893a62796b88e6e8c..b1018a36426185eb56d927877339117f353ec22b 100644
--- a/etc/kayobe/monitoring.yml
+++ b/etc/kayobe/monitoring.yml
@@ -88,6 +88,27 @@
 # singleplatform-eng.users role.
 #monitoring_users:
 
+###############################################################################
+# Monitoring node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#monitoring_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#monitoring_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#monitoring_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#monitoring_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/etc/kayobe/seed-hypervisor.yml b/etc/kayobe/seed-hypervisor.yml
index b14c82344f87893f77731f4c93cb8cc8e370eddc..792432b28defd2e8011727e9824eab41048a7da7 100644
--- a/etc/kayobe/seed-hypervisor.yml
+++ b/etc/kayobe/seed-hypervisor.yml
@@ -104,6 +104,27 @@
 # singleplatform-eng.users role.
 #seed_hypervisor_users:
 
+###############################################################################
+# Seed hypervisor node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#seed_hypervisor_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#seed_hypervisor_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#seed_hypervisor_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#seed_hypervisor_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/etc/kayobe/seed.yml b/etc/kayobe/seed.yml
index 35f2aadaaea899f68a3b4fc5ec5551c4081c6b4d..630d57f317489db0f662e330990b8fbda4e44239 100644
--- a/etc/kayobe/seed.yml
+++ b/etc/kayobe/seed.yml
@@ -97,6 +97,27 @@
 #
 #seed_containers:
 
+###############################################################################
+# Seed node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#seed_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#seed_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#seed_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#seed_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/etc/kayobe/storage.yml b/etc/kayobe/storage.yml
index 47f63dbaa832affc7e1991120ce3e6430814fa11..7aa0d48e9b5283dd7fa0ded9d45b24bc8ccde1ce 100644
--- a/etc/kayobe/storage.yml
+++ b/etc/kayobe/storage.yml
@@ -120,6 +120,27 @@
 # singleplatform-eng.users role.
 #storage_users:
 
+###############################################################################
+# Storage node firewalld configuration.
+
+# Whether to install and enable firewalld.
+#storage_firewalld_enabled:
+
+# A list of zones to create. Each item is a dict containing a 'zone' item.
+#storage_firewalld_zones:
+
+# A firewalld zone to set as the default. Default is unset, in which case the
+# default zone will not be changed.
+#storage_firewalld_default_zone:
+
+# A list of firewall rules to apply. Each item is a dict containing arguments
+# to pass to the firewalld module. Arguments are omitted if not provided, with
+# the following exceptions:
+# - offline: true
+# - permanent: true
+# - state: enabled
+#storage_firewalld_rules:
+
 ###############################################################################
 # Dummy variable to allow Ansible to accept this file.
 workaround_ansible_issue_8743: yes
diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py
index 38afdc6fe79d25494b4fb9bc18a5465944fc18d0..adc433dd4d178e966d5709b2b5e194e1b9690771 100644
--- a/kayobe/cli/commands.py
+++ b/kayobe/cli/commands.py
@@ -413,6 +413,7 @@ class SeedHypervisorHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin,
     * Optionally, wipe unmounted disk partitions (--wipe-disks).
     * Configure user accounts, group associations, and authorised SSH keys.
     * Configure the host's network interfaces.
+    * Configure a firewall.
     * Set sysctl parameters.
     * Configure timezone and ntp.
     * Optionally, configure software RAID arrays.
@@ -453,7 +454,7 @@ class SeedHypervisorHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin,
         if parsed_args.wipe_disks:
             playbooks += _build_playbook_list("wipe-disks")
         playbooks += _build_playbook_list(
-            "users", "dev-tools", "network", "sysctl", "time",
+            "users", "dev-tools", "network", "firewall", "sysctl", "time",
             "mdadm", "luks", "lvm", "seed-hypervisor-libvirt-host")
         self.run_kayobe_playbooks(parsed_args, playbooks,
                                   limit="seed-hypervisor")
@@ -571,6 +572,7 @@ class SeedHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
     * Configure user accounts, group associations, and authorised SSH keys.
     * Disable SELinux.
     * Configure the host's network interfaces.
+    * Configure a firewall.
     * Set sysctl parameters.
     * Configure IP routing and source NAT.
     * Disable bootstrap interface configuration.
@@ -607,7 +609,7 @@ class SeedHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
         if parsed_args.wipe_disks:
             playbooks += _build_playbook_list("wipe-disks")
         playbooks += _build_playbook_list(
-            "users", "dev-tools", "disable-selinux", "network",
+            "users", "dev-tools", "disable-selinux", "network", "firewall",
             "sysctl", "ip-routing", "snat", "disable-glean", "time",
             "mdadm", "luks", "lvm", "docker-devicemapper",
             "kolla-ansible-user", "kolla-pip", "kolla-target-venv")
@@ -946,6 +948,7 @@ class OvercloudHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
     * Configure user accounts, group associations, and authorised SSH keys.
     * Disable SELinux.
     * Configure the host's network interfaces.
+    * Configure a firewall.
     * Set sysctl parameters.
     * Disable bootstrap interface configuration.
     * Configure timezone and ntp.
@@ -980,7 +983,7 @@ class OvercloudHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
         if parsed_args.wipe_disks:
             playbooks += _build_playbook_list("wipe-disks")
         playbooks += _build_playbook_list(
-            "users", "dev-tools", "disable-selinux", "network",
+            "users", "dev-tools", "disable-selinux", "network", "firewall",
             "sysctl", "disable-glean", "disable-cloud-init", "time",
             "mdadm", "luks", "lvm", "docker-devicemapper",
             "kolla-ansible-user", "kolla-pip", "kolla-target-venv")
diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py
index 4e2a0f55b386abd2b8ee1bab9184e919f429a8e4..2aa509e95d37df6122333b6f628d62db18513289 100644
--- a/kayobe/tests/unit/cli/test_commands.py
+++ b/kayobe/tests/unit/cli/test_commands.py
@@ -324,6 +324,7 @@ class TestCase(unittest.TestCase):
                     utils.get_data_files_path("ansible", "users.yml"),
                     utils.get_data_files_path("ansible", "dev-tools.yml"),
                     utils.get_data_files_path("ansible", "network.yml"),
+                    utils.get_data_files_path("ansible", "firewall.yml"),
                     utils.get_data_files_path("ansible", "sysctl.yml"),
                     utils.get_data_files_path("ansible", "time.yml"),
                     utils.get_data_files_path("ansible", "mdadm.yml"),
@@ -496,6 +497,7 @@ class TestCase(unittest.TestCase):
                     utils.get_data_files_path(
                         "ansible", "disable-selinux.yml"),
                     utils.get_data_files_path("ansible", "network.yml"),
+                    utils.get_data_files_path("ansible", "firewall.yml"),
                     utils.get_data_files_path("ansible", "sysctl.yml"),
                     utils.get_data_files_path("ansible", "ip-routing.yml"),
                     utils.get_data_files_path("ansible", "snat.yml"),
@@ -1041,6 +1043,7 @@ class TestCase(unittest.TestCase):
                     utils.get_data_files_path(
                         "ansible", "disable-selinux.yml"),
                     utils.get_data_files_path("ansible", "network.yml"),
+                    utils.get_data_files_path("ansible", "firewall.yml"),
                     utils.get_data_files_path("ansible", "sysctl.yml"),
                     utils.get_data_files_path("ansible", "disable-glean.yml"),
                     utils.get_data_files_path(
diff --git a/releasenotes/notes/firewalld-48dd2efd52c79252.yaml b/releasenotes/notes/firewalld-48dd2efd52c79252.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a6feffa4c7d0446e1ecf8989701ebb055bead410
--- /dev/null
+++ b/releasenotes/notes/firewalld-48dd2efd52c79252.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Adds support for configuring a firewall via firewalld on CentOS. See `story
+    2008991 <https://storyboard.openstack.org/#!/story/2008991>`__ for details.