diff --git a/doc/source/container-roles.rst b/doc/source/container-roles.rst
index 8d16c7c24..dd4ce5784 100644
--- a/doc/source/container-roles.rst
+++ b/doc/source/container-roles.rst
@@ -10,6 +10,7 @@ Container Roles
 .. zuul:autorole:: ensure-openshift
 .. zuul:autorole:: ensure-podman
 .. zuul:autorole:: ensure-skopeo
+.. zuul:autorole:: ensure-quay-repo
 .. zuul:autorole:: pause-buildset-registry
 .. zuul:autorole:: promote-container-image
 .. zuul:autorole:: promote-docker-image
diff --git a/roles/ensure-quay-repo/README.rst b/roles/ensure-quay-repo/README.rst
new file mode 100644
index 000000000..5db7ea907
--- /dev/null
+++ b/roles/ensure-quay-repo/README.rst
@@ -0,0 +1,52 @@
+This role primarily exists to create a new public repository in quay.
+This role can be used to create private repos as well, but repos are
+created by default in quay if you simply push to them.
+
+Users of this role will need to generate an application token with
+`create repository` permissions. Additional permissions are not
+necessary.
+
+When invoking this role you should set no_log: true on the
+`include_role` task to prevent disclosure of your token.
+
+** Role Variables **
+
+.. zuul:rolevar:: container_registry_credentials
+   :type: dict
+
+   Required.  This is expected to be a Zuul secret in dictionary form.
+   For convenience this is in the same format as the
+   ``container_registry_credentials`` variable used by the other container
+   roles. Specify an ``api_token`` which is issued from an application
+   assigned to an organisation.  See `<https://docs.quay.io/api/>`__
+
+   Example:
+
+   .. code-block:: yaml
+
+      container_registry_credentials:
+        quay.io:
+          api_token: 'abcd1234'
+
+.. zuul:rolevar:: container_images
+   :type: list
+
+   Required. A list of dictionaries. This provides info about the image
+   repositories to be created in a quay registry. For convenience this
+   is in the same format as the ``container_images`` variable used by other
+   container roles. Specify a ``registry`` (this should match up with your
+   credentials to locate the api token), ``namespace``, ``repo_shortname``,
+   ``repo_description``, ``visibility``, and ``api_url`` attributes.
+
+   By default visibility will be ``public`` and ``api_url`` will be
+   ``https://{{ registry }}``.
+
+   Example:
+
+   .. code-block:: yaml
+
+      container_images:
+        - registry: quay.io
+          namespace: myquayorg
+          repo_shortname: myimage
+          repo_description: The best container image
diff --git a/roles/ensure-quay-repo/tasks/create.yaml b/roles/ensure-quay-repo/tasks/create.yaml
new file mode 100644
index 000000000..00f475860
--- /dev/null
+++ b/roles/ensure-quay-repo/tasks/create.yaml
@@ -0,0 +1,49 @@
+- name: Set quay_root_url
+  set_fact:
+    quay_root_url: "https://{{ zj_image.registry }}"
+  when: zj_image.api_url is not defined
+
+- name: Alias api_url
+  set_fact:
+    quay_root_url: "{{ zj_image.api_url }}"
+  when: zj_image.api_url is defined
+
+- name: Set quay_repo_visibility
+  set_fact:
+    quay_repo_visibility: "public"
+  when: zj_image.visibility is not defined
+
+- name: Alias visibility
+  set_fact:
+    quay_repo_visibility: "{{ zj_image.visibility }}"
+  when: zj_image.visibility is defined
+
+- name: Create the repo in quay
+  no_log: true
+  uri:
+    url: "{{ quay_root_url }}/api/v1/repository"
+    method: POST
+    body_format: json
+    body:
+      namespace: "{{ zj_image.namespace }}"
+      repository: "{{ zj_image.repo_shortname}}"
+      description: "{{ zj_image.repo_description }}"
+      visibility: "{{ quay_repo_visibility }}"
+    headers:
+      Content-Type: application/json
+      Authorization: "Bearer {{ container_registry_credentials[zj_image.registry].api_token }}"
+    status_code:
+      - 201
+      # 400 is returned when the repo already exists.
+      # We double check this below.
+      - 400
+  register: quay_repo_create
+  delay: 5
+  retries: 3
+
+- name: Fail if repo doesn't exist and we got a 400 status code
+  when:
+    - quay_repo_create.status == 400
+    - "'error_message' not in quay_repo_create.json or ('error_message' in quay_repo_create.json and quay_repo_create.json.error_message != 'Repository already exists')"
+  fail:
+    msg: "Could not create {{ quay_root_url }}/{{ zj_image.namespace }}/{{ zj_image.repo_shortname }}"
diff --git a/roles/ensure-quay-repo/tasks/main.yaml b/roles/ensure-quay-repo/tasks/main.yaml
new file mode 100644
index 000000000..5da52871d
--- /dev/null
+++ b/roles/ensure-quay-repo/tasks/main.yaml
@@ -0,0 +1,25 @@
+- name: Verify repository names
+  when: |
+    container_registry_credentials is defined
+    and zj_image.registry not in container_registry_credentials
+  loop: "{{ container_images }}"
+  loop_control:
+    loop_var: zj_image
+  fail:
+    msg: "{{ zj_image.registry }} credentials not found"
+
+- name: Verify repository permission
+  when: |
+    container_registry_credentials[zj_image.registry].repository is defined and
+    not zj_image.repository | regex_search(container_registry_credentials[zj_image.registry].repository)
+  loop: "{{ container_images }}"
+  loop_control:
+    loop_var: zj_image
+  fail:
+    msg: "{{ zj_image.repository }} not permitted by {{ container_registry_credentials[zj_image.registry].repository }}"
+
+- name: Create repository in quay registry
+  loop: "{{ container_images }}"
+  loop_control:
+    loop_var: zj_image
+  include_tasks: create.yaml