diff --git a/doc/source/general-roles.rst b/doc/source/general-roles.rst
index b856c517d..e439836a6 100644
--- a/doc/source/general-roles.rst
+++ b/doc/source/general-roles.rst
@@ -45,6 +45,7 @@ General Purpose Roles
 .. zuul:autorole:: start-zuul-console
 .. zuul:autorole:: test-setup
 .. zuul:autorole:: trigger-readthedocs
+.. zuul:autorole:: update-json-file
 .. zuul:autorole:: upload-artifactory
 .. zuul:autorole:: upload-git-mirror
 .. zuul:autorole:: validate-dco-license
diff --git a/roles/ensure-docker/tasks/docker-setup.yaml b/roles/ensure-docker/tasks/docker-setup.yaml
index e398c70ee..52c39b2c2 100644
--- a/roles/ensure-docker/tasks/docker-setup.yaml
+++ b/roles/ensure-docker/tasks/docker-setup.yaml
@@ -15,34 +15,14 @@
 - name: Update docker daemon configuration
   when: docker_userland_proxy is defined
   block:
-    - name: Check if docker daemon configuration exists
-      stat:
-        path: /etc/docker/daemon.json
-      register: docker_config_stat
-    - name: Load docker daemon configuration
-      when: docker_config_stat.stat.exists
-      slurp:
-        path: /etc/docker/daemon.json
-      register: docker_config
-    - name: Parse docker daemon configuration
-      when: docker_config_stat.stat.exists
-      set_fact:
-        docker_config: "{{ docker_config.content | b64decode | from_json }}"
-    - name: Set default docker daemon configuration
-      when: not docker_config_stat.stat.exists
-      set_fact:
-        docker_config: {}
-    - name: Add registry to docker daemon configuration
+    - name: Add proxy config
+      include_role:
+        name: update-json-file
       vars:
-        new_config:
+        update_json_file_name: /etc/docker/daemon.json
+        update_json_file_combine:
           userland-proxy: "{{ docker_userland_proxy }}"
-      set_fact:
-        docker_config: "{{ docker_config | combine(new_config) }}"
-    - name: Save docker daemon configuration
-      copy:
-        content: "{{ docker_config | to_nice_json }}"
-        dest: /etc/docker/daemon.json
-      become: true
+        update_json_file_become: true
 
 - name: Reset ssh connection to pick up docker group
   meta: reset_connection
diff --git a/roles/update-json-file/README.rst b/roles/update-json-file/README.rst
new file mode 100644
index 000000000..1f93f211a
--- /dev/null
+++ b/roles/update-json-file/README.rst
@@ -0,0 +1,48 @@
+Update JSON file
+
+This role reads a JSON file, merges it with supplied values using
+Ansible's ``combine`` filter and writes it back out.  It is useful for
+updating configuration files.  Note this role is not currently
+idempotent and will write the file each time.
+
+**Role Variables**
+
+.. zuul:rolevar:: update_json_file_name
+   :type: path
+
+   The path to the file to edit.
+
+.. zuul:rolevar:: update_json_file_combine
+   :type: object
+
+   The data to be combined with the existing file data.  This uses the
+   Jinja ``combine`` filter.
+
+.. zuul:rolevar:: update_json_file_debug
+   :default: false
+   :type: bool
+
+   If enabled, output the combined result in a debug task.
+
+.. zuul:rolevar:: update_json_file_default
+   :default: {}
+
+   The default value if the given file does not exist.
+
+.. zuul:rolevar:: update_json_file_become
+   :type: bool
+   :default: false
+
+   The ``become:`` status when writing out the new file.
+
+.. zuul:rolevar:: update_json_file_mode
+
+   The mode for the combined file.
+
+.. zuul:rolevar:: update_json_file_user
+
+   The user for the combined file.
+
+.. zuul:rolevar:: update_json_file_group
+
+   The group for the combined file.
diff --git a/roles/update-json-file/defaults/main.yaml b/roles/update-json-file/defaults/main.yaml
new file mode 100644
index 000000000..de0dd5d71
--- /dev/null
+++ b/roles/update-json-file/defaults/main.yaml
@@ -0,0 +1,3 @@
+update_json_file_debug: false
+update_json_file_become: false
+update_json_file_default: {}
diff --git a/roles/update-json-file/tasks/main.yaml b/roles/update-json-file/tasks/main.yaml
new file mode 100644
index 000000000..7362f17af
--- /dev/null
+++ b/roles/update-json-file/tasks/main.yaml
@@ -0,0 +1,38 @@
+- name: Check if file exists
+  stat:
+    path: '{{ update_json_file_name }}'
+  register: _stat
+
+- name: Load existing file
+  when: _stat.stat.exists
+  slurp:
+    path: '{{ update_json_file_name }}'
+  register: _file
+
+- name: Parse exisiting file
+  when: _stat.stat.exists
+  set_fact:
+    _config: "{{ _file.content | b64decode | from_json }}"
+
+- name: Set default for non existing file
+  when: not _stat.stat.exists
+  set_fact:
+    _config: '{{ update_json_file_default }}'
+
+- name: Combine new configuration
+  set_fact:
+    _config: "{{ _config | combine(update_json_file_combine) }}"
+
+- name: Debug _config variable
+  debug:
+    var: _config
+  when: update_json_file_debug
+
+- name: Save new file
+  copy:
+    content: "{{ _config | to_nice_json }}"
+    dest: '{{ update_json_file_name }}'
+    mode: '{{ update_json_file_mode | default(omit) }}'
+    owner: '{{ update_json_file_owner | default(omit) }}'
+    group: '{{ update_json_file_group | default(omit) }}'
+  become: '{{ update_json_file_become }}'
diff --git a/test-playbooks/update-json-file.yaml b/test-playbooks/update-json-file.yaml
new file mode 100644
index 000000000..1f5c4b371
--- /dev/null
+++ b/test-playbooks/update-json-file.yaml
@@ -0,0 +1,40 @@
+- hosts: all
+  tasks:
+
+    - include_role:
+        name: update-json-file
+      vars:
+        update_json_file_name: test.json
+        update_json_file_default:
+          foo: bar
+        update_json_file_combine:
+          moo: boo
+        update_json_file_debug: true
+
+    - include_role:
+        name: update-json-file
+      vars:
+        update_json_file_name: test.json
+        update_json_file_combine:
+          new: content
+          a:
+            - list
+            - of
+            - items
+        update_json_file_debug: true
+
+    - name: Load resulting merged file
+      slurp:
+        path: 'test.json'
+      register: _file
+
+    - name: Parse merged file
+      set_fact:
+        _config: "{{ _file.content | b64decode | from_json }}"
+
+    - assert:
+        that:
+          - _config['foo'] == 'bar'
+          - _config['moo'] == 'boo'
+          - _config['new'] == 'content'
+          - _config['a'] == ['list', 'of', 'items']
diff --git a/zuul-tests.d/general-roles-jobs.yaml b/zuul-tests.d/general-roles-jobs.yaml
index 4500fcdea..2c1c5a8b9 100644
--- a/zuul-tests.d/general-roles-jobs.yaml
+++ b/zuul-tests.d/general-roles-jobs.yaml
@@ -642,6 +642,14 @@
         - name: fedora-32
           label: fedora-32
 
+- job:
+    name: zuul-jobs-test-update-json-file
+    description: Test the json edit role
+    run: test-playbooks/update-json-file.yaml
+    files:
+      - test-playbooks/update-json-file.yaml
+      - roles/update-json-file/.*
+
 # -* AUTOGENERATED *-
 #  The following project section is autogenerated by
 #    tox -e update-test-platforms
@@ -692,6 +700,7 @@
         - zuul-jobs-test-upload-git-mirror
         - zuul-jobs-test-shake-build
         - zuul-jobs-test-ensure-zookeeper
+        - zuul-jobs-test-update-json-file
     gate:
       jobs: &id001
         - zuul-jobs-test-add-authorized-keys
@@ -732,5 +741,6 @@
         - zuul-jobs-test-upload-git-mirror
         - zuul-jobs-test-shake-build
         - zuul-jobs-test-ensure-zookeeper
+        - zuul-jobs-test-update-json-file
     periodic-weekly:
       jobs: *id001