diff --git a/README-dnsforwardzone.md b/README-dnsforwardzone.md
new file mode 100644
index 0000000000000000000000000000000000000000..81919295a049cf25a966d475b153fd8e5ada9e23
--- /dev/null
+++ b/README-dnsforwardzone.md
@@ -0,0 +1,112 @@
+Dnsforwardzone module
+=====================
+
+Description
+-----------
+
+The dnsforwardzone module allows the addition and removal of dns forwarders from the IPA DNS config.
+
+It is desgined to follow the IPA api as closely as possible while ensuring ease of use.
+
+
+Features
+--------
+* DNS zone management
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the ipadnsforwardzone module.
+
+Requirements
+------------
+**Controller**
+* Ansible version: 2.8+
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+
+Example playbook to ensure presence of a forwardzone to ipa DNS:
+
+```yaml
+---
+- name: Playbook to handle add a forwarder
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: ensure presence of forwardzone for DNS requests for example.com to 8.8.8.8
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      forwardpolicy: first
+      skip_overlap_check: true
+
+  - name: ensure the forward zone is disabled
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: disabled
+
+  - name: ensure presence of multiple upstream DNS servers for example.com
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+        - 4.4.4.4
+
+  - name: ensure presence of another forwarder to any existing ones for example.com
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 1.1.1.1
+      action: member
+
+  - name: ensure the forwarder for example.com does not exists (delete it if needed)
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent
+```
+
+Variables
+=========
+
+ipagroup
+-------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`name` \| `cn` | Zone name (FQDN). | yes if `state` == `present`
+`forwarders` \| `idnsforwarders` |  Per-zone conditional forwarding policy. Possible values are `only`, `first`, `none`) | no
+`forwardpolicy` \| `idnsforwardpolicy` | Per-zone conditional forwarding policy. Set to "none" to disable forwarding to global forwarder for this zone. In that case, conditional zone forwarders are disregarded. | no
+`skip_overlap_check` | Force DNS zone creation even if it will overlap with an existing zone. Defaults to False. | no
+`action` | Work on group or member level. It can be on of `member` or `dnsforwardzone` and defaults to `dnsforwardzone`. | no
+`state` | The state to ensure. It can be one of `present`, `absent`, `enabled` or `disabled`, default: `present`. | yes
+
+
+Authors
+=======
+
+Chris Procter
diff --git a/README.md b/README.md
index 5efbd08884101f434adccc282e9c32226313ff17..f95458a37ca32ea4abccb8319dbf36709524e947 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Features
 * Cluster deployments: Server, replicas and clients in one playbook
 * One-time-password (OTP) support for client installation
 * Repair mode for clients
+* Modules for dns forwarder management
 * Modules for group management
 * Modules for hbacrule management
 * Modules for hbacsvc management
@@ -403,6 +404,7 @@ Roles
 Modules in plugin/modules
 =========================
 
+* [ipadnsforwardzone](README-dnsforwardzone.md)
 * [ipagroup](README-group.md)
 * [ipahbacrule](README-hbacrule.md)
 * [ipahbacsvc](README-hbacsvc.md)
diff --git a/plugins/modules/ipadnsforwardzone.py b/plugins/modules/ipadnsforwardzone.py
new file mode 100644
index 0000000000000000000000000000000000000000..90bd387652ce30d36c08c056b38163874d98c5d4
--- /dev/null
+++ b/plugins/modules/ipadnsforwardzone.py
@@ -0,0 +1,316 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Authors:
+#   Chris Procter <cprocter@redhat.com>
+#
+# Copyright (C) 2019 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
+
+
+DOCUMENTATION = '''
+---
+module: ipa_dnsforwardzone
+author: chris procter
+short_description: Manage FreeIPA DNS Forwarder Zones
+description:
+- Add and delete an IPA DNS Forwarder Zones using IPA API
+options:
+  ipaadmin_principal:
+    description: The admin principal
+    default: admin
+  ipaadmin_password:
+    description: The admin password
+    required: false
+  name:
+    description:
+    - The DNS zone name which needs to be managed.
+    required: true
+    aliases: ["cn"]
+  state:
+    description: State to ensure
+    required: false
+    default: present
+    choices: ["present", "absent", "enabled", "disabled"]
+  forwarders:
+    description:
+    - List of the DNS servers to forward to
+    required: true
+    type: list
+    aliases: ["idnsforwarders"]
+  forwardpolicy:
+    description: Per-zone conditional forwarding policy
+    required: false
+    default: only
+    choices: ["only", "first", "none"]
+    aliases: ["idnsforwarders"]
+  skip_overlap_check:
+    description:
+    - Force DNS zone creation even if it will overlap with an existing zone.
+    required: false
+    default: false
+'''
+
+EXAMPLES = '''
+# Ensure dns zone is present
+- ipadnsforwardzone:
+    ipaadmin_password: MyPassword123
+    state: present
+    name: example.com
+    forwarders:
+    - 8.8.8.8
+    - 4.4.4.4
+    forwardpolicy: first
+    skip_overlap_check: true
+
+# Ensure that dns zone is removed
+- ipadnsforwardzone:
+    ipaadmin_password: MyPassword123
+    name: example.com
+    state: absent
+'''
+
+RETURN = '''
+'''
+
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.ansible_freeipa_module import temp_kinit, \
+    temp_kdestroy, valid_creds, api_connect, api_command, compare_args_ipa, \
+    module_params_get
+
+
+def find_dnsforwardzone(module, name):
+    _args = {
+        "all": True,
+        "idnsname": name
+    }
+    _result = api_command(module, "dnsforwardzone_find", name, _args)
+
+    if len(_result["result"]) > 1:
+        module.fail_json(
+            msg="There is more than one dnsforwardzone '%s'" % (name))
+    elif len(_result["result"]) == 1:
+        return _result["result"][0]
+    else:
+        return None
+
+
+def gen_args(forwarders, forwardpolicy, skip_overlap_check):
+    _args = {}
+
+    if forwarders is not None:
+        _args["idnsforwarders"] = forwarders
+    if forwardpolicy is not None:
+        _args["idnsforwardpolicy"] = forwardpolicy
+    if skip_overlap_check is not None:
+        _args["skip_overlap_check"] = skip_overlap_check
+
+    return _args
+
+
+def main():
+    ansible_module = AnsibleModule(
+        argument_spec=dict(
+            # general
+            ipaadmin_principal=dict(type="str", default="admin"),
+            ipaadmin_password=dict(type="str", required=False, no_log=True),
+            name=dict(type="str", aliases=["cn"], default=None,
+                      required=True),
+            forwarders=dict(type='list', aliases=["idnsforwarders"],
+                            required=False),
+            forwardpolicy=dict(type='str', aliases=["idnsforwardpolicy"],
+                               required=False,
+                               choices=['only', 'first', 'none']),
+            skip_overlap_check=dict(type='bool', required=False),
+            action=dict(type="str", default="dnsforwardzone",
+                        choices=["member", "dnsforwardzone"]),
+            # state
+            state=dict(type='str', default='present',
+                       choices=['present', 'absent', 'enabled', 'disabled']),
+        ),
+        supports_check_mode=True,
+    )
+
+    ansible_module._ansible_debug = True
+
+    # Get parameters
+    ipaadmin_principal = module_params_get(ansible_module,
+                                           "ipaadmin_principal")
+    ipaadmin_password = module_params_get(ansible_module,
+                                          "ipaadmin_password")
+    name = module_params_get(ansible_module, "name")
+    action = module_params_get(ansible_module, "action")
+    forwarders = module_params_get(ansible_module, "forwarders")
+    forwardpolicy = module_params_get(ansible_module, "forwardpolicy")
+    skip_overlap_check = module_params_get(ansible_module,
+                                           "skip_overlap_check")
+    state = module_params_get(ansible_module, "state")
+
+    # absent stae means delete if the action is NOT member but update if it is
+    # if action is member then update an exisiting resource
+    # and if action is not member then create a resource
+    if state == "absent" and action == "dnsforwardzone":
+        operation = "del"
+    elif action == "member":
+        operation = "update"
+    else:
+        operation = "add"
+
+    if state == "disabled":
+        wants_enable = False
+    else:
+        wants_enable = True
+
+    if operation == "del":
+        invalid = ["forwarders", "forwardpolicy", "skip_overlap_check"]
+        for x in invalid:
+            if vars()[x] is not None:
+                ansible_module.fail_json(
+                    msg="Argument '%s' can not be used with action "
+                    "'%s'" % (x, action))
+
+    changed = False
+    exit_args = {}
+    args = {}
+    ccache_dir = None
+    ccache_name = None
+    is_enabled = "IGNORE"
+    try:
+        # we need to determine 3 variables
+        # args = the values we want to change/set
+        # command = the ipa api command to call del, add, or mod
+        # is_enabled = is the current resource enabled (True)
+        #             disabled (False) and do we care (IGNORE)
+
+        if not valid_creds(ansible_module, ipaadmin_principal):
+            ccache_dir, ccache_name = temp_kinit(ipaadmin_principal,
+                                                 ipaadmin_password)
+        api_connect()
+
+        # Make sure forwardzone exists
+        existing_resource = find_dnsforwardzone(ansible_module, name)
+
+        if existing_resource is None and operation == "update":
+            # does not exist and is updating
+            # trying to update something that doesn't exist, so error
+            ansible_module.fail_json(msg="""dnsforwardzone '%s' is not
+                                                     valid""" % (name))
+        elif existing_resource is None and operation == "del":
+            # does not exists and should be absent
+            # set command
+            command = None
+            # enabled or disabled?
+            is_enabled = "IGNORE"
+        elif existing_resource is not None and operation == "del":
+            # exists but should be absent
+            # set command
+            command = "dnsforwardzone_del"
+            # enabled or disabled?
+            is_enabled = "IGNORE"
+        elif forwarders is None:
+            # forwarders are not defined its not a delete, update state?
+            # set command
+            command = None
+            # enabled or disabled?
+            if existing_resource is not None:
+                is_enabled = existing_resource["idnszoneactive"][0]
+            else:
+                is_enabled = "IGNORE"
+        elif existing_resource is not None and operation == "update":
+            # exists and is updating
+            # calculate the new forwarders and mod
+            # determine args
+            if state != "absent":
+                forwarders = list(set(existing_resource["idnsforwarders"]
+                                      + forwarders))
+            else:
+                forwarders = list(set(existing_resource["idnsforwarders"])
+                                  - set(forwarders))
+            args = gen_args(forwarders, forwardpolicy,
+                            skip_overlap_check)
+            if skip_overlap_check is not None:
+                del args['skip_overlap_check']
+
+            # command
+            if not compare_args_ipa(ansible_module, args, existing_resource):
+                command = "dnsforwardzone_mod"
+            else:
+                command = None
+
+            # enabled or disabled?
+            is_enabled = existing_resource["idnszoneactive"][0]
+
+        elif existing_resource is None and operation == "add":
+            # does not exist but should be present
+            # determine args
+            args = gen_args(forwarders, forwardpolicy,
+                            skip_overlap_check)
+            # set command
+            command = "dnsforwardzone_add"
+            # enabled or disabled?
+            is_enabled = "TRUE"
+
+        elif existing_resource is not None and operation == "add":
+            # exists and should be present, has it changed?
+            # determine args
+            args = gen_args(forwarders, forwardpolicy, skip_overlap_check)
+            if skip_overlap_check is not None:
+                del args['skip_overlap_check']
+
+            # set command
+            if not compare_args_ipa(ansible_module, args, existing_resource):
+                command = "dnsforwardzone_mod"
+            else:
+                command = None
+
+            # enabled or disabled?
+            is_enabled = existing_resource["idnszoneactive"][0]
+
+        # if command is set then run it with the args
+        if command is not None:
+            api_command(ansible_module, command, name, args)
+            changed = True
+
+        # does the enabled state match what we want (if we care)
+        if is_enabled != "IGNORE":
+            if wants_enable and is_enabled != "TRUE":
+                api_command(ansible_module, "dnsforwardzone_enable",
+                            name, {})
+                changed = True
+            elif not wants_enable and is_enabled != "FALSE":
+                api_command(ansible_module, "dnsforwardzone_disable",
+                            name, {})
+                changed = True
+
+    except Exception as e:
+        ansible_module.fail_json(msg=str(e))
+
+    finally:
+        temp_kdestroy(ccache_dir, ccache_name)
+
+    # Done
+    ansible_module.exit_json(changed=changed, dnsforwardzone=exit_args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/dnsforwardzone/test_dnsforwardzone.yml b/tests/dnsforwardzone/test_dnsforwardzone.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1a45e826f702edf7d31e8662f5544bda3c285f1d
--- /dev/null
+++ b/tests/dnsforwardzone/test_dnsforwardzone.yml
@@ -0,0 +1,214 @@
+---
+- name: Test dnsforwardzone
+  hosts: ipaserver
+  become: true
+  gather_facts: false
+
+  tasks:
+  - name: ensure forwardzone example.com is absent - prep
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent
+
+  - name: ensure forwardzone example.com is created
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      forwardpolicy: first
+      skip_overlap_check: true
+    register: result
+    failed_when: not result.changed
+
+  - name: ensure forwardzone example.com is present again
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      forwardpolicy: first
+      skip_overlap_check: true
+    register: result
+    failed_when: result.changed
+
+  - name: ensure forwardzone example.com has two forwarders
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+        - 4.4.4.4
+      forwardpolicy: first
+      skip_overlap_check: true
+    register: result
+    failed_when: not result.changed
+
+  - name: ensure forwardzone example.com has one forwarder again
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      forwardpolicy: first
+      skip_overlap_check: true
+      state: present
+    register: result
+    failed_when: not result.changed
+
+  - name: skip_overlap_check can only be set on creation so change nothing
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      forwardpolicy: first
+      skip_overlap_check: false
+      state: present
+    register: result
+    failed_when: result.changed
+
+  - name: change all the things at once
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+        - 4.4.4.4
+      forwardpolicy: only
+      skip_overlap_check: false
+    register: result
+    failed_when: not result.changed
+
+  - name: ensure forwardzone example.com is absent for next testset
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent
+
+  - name: ensure forwardzone example.com is created with minimal args
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      skip_overlap_check: true
+      forwarders:
+        - 8.8.8.8
+    register: result
+    failed_when: not result.changed
+
+  - name: add a forwarder to any existing ones
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 4.4.4.4
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: check the list of forwarders is what we expect
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 4.4.4.4
+        - 8.8.8.8
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: remove a single forwarder
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: absent
+      name: example.com
+      forwarders:
+        - 8.8.8.8
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: check the list of forwarders is what we expect now
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 4.4.4.4
+      action: member
+    register: result
+    failed_when: result.changed
+
+  - name: ensure forwardzone example.com is absent again
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent
+
+  - name: try to create a new forwarder with action=member
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: present
+      name: example.com
+      forwarders:
+        - 4.4.4.4
+      action: member
+      skip_overlap_check: true
+    register: result
+    failed_when: result.changed
+
+  - name: ensure forwardzone example.com is absent - tidy up
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent
+
+  - name: try to create a new forwarder is disabled state
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      state: disabled
+      name: example.com
+      forwarders:
+        - 4.4.4.4
+      skip_overlap_check: true
+    register: result
+    failed_when: not result.changed
+
+  - name: enable the forwarder
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: enabled
+    register: result
+    failed_when: not result.changed
+
+  - name: disable the forwarder again
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: disabled
+      action: member
+    register: result
+    failed_when: not result.changed
+
+  - name: ensure it stays disabled
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: disabled
+    register: result
+    failed_when: result.changed
+
+  - name: ensure forwardzone example.com is absent - tidy up
+    ipadnsforwardzone:
+      ipaadmin_password: password01
+      name: example.com
+      state: absent