diff --git a/kayobe/ansible.py b/kayobe/ansible.py
index fed57ea4f487ce20e18e781bf06d1b675d437588..9dc7cb4db8887a581f2dca0f64382e5395a74983 100644
--- a/kayobe/ansible.py
+++ b/kayobe/ansible.py
@@ -22,6 +22,7 @@ import sys
 import tempfile
 
 import ansible.constants
+from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
 
 from kayobe import exception
 from kayobe import utils
@@ -299,6 +300,18 @@ def run_playbook(parsed_args, playbook, *args, **kwargs):
     return run_playbooks(parsed_args, [playbook], *args, **kwargs)
 
 
+def _sanitise_hostvar(var):
+    """Sanitise a host variable."""
+    if isinstance(var, AnsibleVaultEncryptedUnicode):
+        return "******"
+    # Recursively sanitise dicts and lists.
+    if isinstance(var, dict):
+        return {k: _sanitise_hostvar(v) for k, v in var.items()}
+    if isinstance(var, list):
+        return [_sanitise_hostvar(v) for v in var]
+    return var
+
+
 def config_dump(parsed_args, host=None, hosts=None, var_name=None,
                 facts=None, extra_vars=None, tags=None, verbose_level=None):
     dump_dir = tempfile.mkdtemp()
@@ -324,7 +337,8 @@ def config_dump(parsed_args, host=None, hosts=None, var_name=None,
             LOG.debug("Found dump file %s", path)
             inventory_hostname, ext = os.path.splitext(path)
             if ext == ".yml":
-                hvars = utils.read_yaml_file(os.path.join(dump_dir, path))
+                dump_file = os.path.join(dump_dir, path)
+                hvars = utils.read_config_dump_yaml_file(dump_file)
                 if host:
                     return hvars
                 else:
@@ -332,7 +346,7 @@ def config_dump(parsed_args, host=None, hosts=None, var_name=None,
             else:
                 LOG.warning("Unexpected extension on config dump file %s",
                             path)
-        return hostvars
+        return {k: _sanitise_hostvar(v) for k, v in hostvars.items()}
     finally:
         shutil.rmtree(dump_dir)
 
diff --git a/kayobe/tests/unit/test_ansible.py b/kayobe/tests/unit/test_ansible.py
index 87568841757b1a0c9c4c09999801ea90d4803fed..40b2437d8c42d05cd5abef287105fddc430d2a64 100644
--- a/kayobe/tests/unit/test_ansible.py
+++ b/kayobe/tests/unit/test_ansible.py
@@ -583,7 +583,7 @@ class TestCase(unittest.TestCase):
                           ansible.run_playbooks, parsed_args, ["command"])
 
     @mock.patch.object(shutil, 'rmtree')
-    @mock.patch.object(utils, 'read_yaml_file')
+    @mock.patch.object(utils, 'read_config_dump_yaml_file')
     @mock.patch.object(os, 'listdir')
     @mock.patch.object(ansible, 'run_playbook')
     @mock.patch.object(tempfile, 'mkdtemp')
@@ -621,6 +621,70 @@ class TestCase(unittest.TestCase):
             mock.call(os.path.join(dump_dir, "host2.yml")),
         ])
 
+    @mock.patch.object(shutil, 'rmtree')
+    @mock.patch.object(utils, 'read_file')
+    @mock.patch.object(os, 'listdir')
+    @mock.patch.object(ansible, 'run_playbook')
+    @mock.patch.object(tempfile, 'mkdtemp')
+    def test_config_dump_vaulted(self, mock_mkdtemp, mock_run, mock_listdir,
+                                 mock_read, mock_rmtree):
+        parser = argparse.ArgumentParser()
+        parsed_args = parser.parse_args([])
+        dump_dir = "/path/to/dump"
+        mock_mkdtemp.return_value = dump_dir
+        mock_listdir.return_value = ["host1.yml", "host2.yml"]
+        config = """---
+key1: !vault |
+  $ANSIBLE_VAULT;1.1;AES256
+  633230623736383232323862393364323037343430393530316636363961626361393133646437
+  643438663261356433656365646138666133383032376532310a63323432306431303437623637
+  346236316161343635636230613838316566383933313338636237616338326439616536316639
+  6334343462333062363334300a3930313762313463613537626531313230303731343365643766
+  666436333037
+key2: value2
+key3:
+  - !vault |
+    $ANSIBLE_VAULT;1.1;AES256
+    633230623736383232323862393364323037343430393530316636363961626361393133646437
+    643438663261356433656365646138666133383032376532310a63323432306431303437623637
+    346236316161343635636230613838316566383933313338636237616338326439616536316639
+    6334343462333062363334300a3930313762313463613537626531313230303731343365643766
+    666436333037
+"""
+        config_nested = """---
+key1:
+  key2: !vault |
+    $ANSIBLE_VAULT;1.1;AES256
+    633230623736383232323862393364323037343430393530316636363961626361393133646437
+    643438663261356433656365646138666133383032376532310a63323432306431303437623637
+    346236316161343635636230613838316566383933313338636237616338326439616536316639
+    6334343462333062363334300a3930313762313463613537626531313230303731343365643766
+    666436333037
+"""
+        mock_read.side_effect = [config, config_nested]
+        result = ansible.config_dump(parsed_args)
+        expected_result = {
+            "host1": {"key1": "******", "key2": "value2", "key3": ["******"]},
+            "host2": {"key1": {"key2": "******"}},
+        }
+        self.assertEqual(result, expected_result)
+        dump_config_path = utils.get_data_files_path(
+            "ansible", "dump-config.yml")
+        mock_run.assert_called_once_with(parsed_args,
+                                         dump_config_path,
+                                         extra_vars={
+                                             "dump_path": dump_dir,
+                                         },
+                                         check_output=True, tags=None,
+                                         verbose_level=None, check=False,
+                                         list_tasks=False, diff=False)
+        mock_rmtree.assert_called_once_with(dump_dir)
+        mock_listdir.assert_any_call(dump_dir)
+        mock_read.assert_has_calls([
+            mock.call(os.path.join(dump_dir, "host1.yml")),
+            mock.call(os.path.join(dump_dir, "host2.yml")),
+        ])
+
     @mock.patch.object(utils, 'galaxy_role_install', autospec=True)
     @mock.patch.object(utils, 'is_readable_file', autospec=True)
     @mock.patch.object(os, 'makedirs', autospec=True)
diff --git a/kayobe/tests/unit/test_utils.py b/kayobe/tests/unit/test_utils.py
index 89263ef82a67a9120ce2b761493481813b4e2c58..cd126cd4b3387523ef0e1444c321a18c80c19d6e 100644
--- a/kayobe/tests/unit/test_utils.py
+++ b/kayobe/tests/unit/test_utils.py
@@ -17,6 +17,7 @@ import subprocess
 import unittest
 from unittest import mock
 
+from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
 import yaml
 
 from kayobe import exception
@@ -127,6 +128,59 @@ key2: value2
         mock_read.return_value = "[1{!"
         self.assertRaises(SystemExit, utils.read_yaml_file, "/path/to/file")
 
+    @mock.patch.object(utils, "read_file")
+    def test_read_config_dump_yaml_file(self, mock_read):
+        config = """---
+key1: value1
+key2: value2
+"""
+        mock_read.return_value = config
+        result = utils.read_config_dump_yaml_file("/path/to/file")
+        self.assertEqual(result, {"key1": "value1", "key2": "value2"})
+        mock_read.assert_called_once_with("/path/to/file")
+
+    @mock.patch.object(utils, "read_file")
+    def test_read_config_dump_yaml_file_vaulted(self, mock_read):
+        config = """---
+key1: !vault |
+  $ANSIBLE_VAULT;1.1;AES256
+  633230623736383232323862393364323037343430393530316636363961626361393133646437
+  643438663261356433656365646138666133383032376532310a63323432306431303437623637
+  346236316161343635636230613838316566383933313338636237616338326439616536316639
+  6334343462333062363334300a3930313762313463613537626531313230303731343365643766
+  666436333037
+key2: value2
+key3:
+  - !vault |
+    $ANSIBLE_VAULT;1.1;AES256
+    633230623736383232323862393364323037343430393530316636363961626361393133646437
+    643438663261356433656365646138666133383032376532310a63323432306431303437623637
+    346236316161343635636230613838316566383933313338636237616338326439616536316639
+    6334343462333062363334300a3930313762313463613537626531313230303731343365643766
+    666436333037
+"""
+        mock_read.return_value = config
+        result = utils.read_config_dump_yaml_file("/path/to/file")
+        # Can't read the value without an encryption key, so just check type.
+        self.assertTrue(isinstance(result["key1"],
+                                   AnsibleVaultEncryptedUnicode))
+        self.assertEqual(result["key2"], "value2")
+        self.assertTrue(isinstance(result["key3"][0],
+                                   AnsibleVaultEncryptedUnicode))
+        mock_read.assert_called_once_with("/path/to/file")
+
+    @mock.patch.object(utils, "read_file")
+    def test_read_config_dump_yaml_file_open_failure(self, mock_read):
+        mock_read.side_effect = IOError
+        self.assertRaises(SystemExit, utils.read_config_dump_yaml_file,
+                          "/path/to/file")
+
+    @mock.patch.object(utils, "read_file")
+    def test_read_config_dump_yaml_file_not_yaml(self, mock_read):
+        mock_read.return_value = "[1{!"
+        self.assertRaises(SystemExit, utils.read_config_dump_yaml_file,
+                          "/path/to/file")
+
     @mock.patch.object(subprocess, "check_call")
     def test_run_command(self, mock_call):
         output = utils.run_command(["command", "to", "run"])
diff --git a/kayobe/utils.py b/kayobe/utils.py
index ba9fdaad946f6dcfb87c6943702d5d8baf5e03d2..04094db487e04daa174f86d52d7da10e9fd961e9 100644
--- a/kayobe/utils.py
+++ b/kayobe/utils.py
@@ -24,6 +24,7 @@ import shutil
 import subprocess
 import sys
 
+from ansible.parsing.yaml.loader import AnsibleLoader
 import yaml
 
 from kayobe import exception
@@ -153,11 +154,28 @@ def read_yaml_file(path):
     try:
         content = read_file(path)
     except IOError as e:
-        print("Failed to open config dump file %s: %s" %
+        print("Failed to open YAML file %s: %s" %
               (path, repr(e)))
         sys.exit(1)
     try:
         return yaml.safe_load(content)
+    except yaml.YAMLError as e:
+        print("Failed to decode YAML file %s: %s" %
+              (path, repr(e)))
+        sys.exit(1)
+
+
+def read_config_dump_yaml_file(path):
+    """Read and decode a configuration dump YAML file."""
+    try:
+        content = read_file(path)
+    except IOError as e:
+        print("Failed to open config dump file %s: %s" %
+              (path, repr(e)))
+        sys.exit(1)
+    try:
+        # AnsibleLoader supports loading vault encrypted variables.
+        return AnsibleLoader(content).get_single_data()
     except yaml.YAMLError as e:
         print("Failed to decode config dump YAML file %s: %s" %
               (path, repr(e)))
diff --git a/releasenotes/notes/config-dump-vault-edc615e475f234ac.yaml b/releasenotes/notes/config-dump-vault-edc615e475f234ac.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f8e6a2fc7305740114aff7ff518c00d5a2e99d59
--- /dev/null
+++ b/releasenotes/notes/config-dump-vault-edc615e475f234ac.yaml
@@ -0,0 +1,7 @@
+---
+fixes:
+  - |
+    Fixes an issue where ``kayobe configuration dump`` would fail when
+    variables are encrypted using Ansible Vault. Encrypted variables are now
+    sanitised in the dump output. `LP#2031390
+    <https://bugs.launchpad.net/kayobe/+bug/2031390>`__