diff --git a/.zuul.yaml b/.zuul.yaml
index d8fdb2d754..667029f843 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -687,6 +687,30 @@
       - testinfra/test_adns.py
       - testinfra/test_ns.py
 
+- job:
+    name: system-config-run-backup
+    parent: system-config-run
+    description: |
+      Run the playbook for backup configuration
+    nodeset:
+      nodes:
+        - name: bridge.openstack.org
+          label: ubuntu-bionic
+        - name: backup01.region.provider.opendev.org
+          label: ubuntu-bionic
+        - name: backup-test01.opendev.org
+          label: ubuntu-bionic
+        - name: backup-test02.opendev.org
+          label: ubuntu-xenial
+    vars:
+      run_playbooks:
+        - playbooks/service-backup.yaml
+    files:
+      - .zuul.yaml
+      - playbooks/roles/backup.*
+      - playbooks/zuul/templates/host_vars/backup.*
+      - testinfra/test_backups.py
+
 - job:
     name: system-config-run-mirror
     parent: system-config-run
@@ -870,6 +894,7 @@
         - system-config-run-base
         - system-config-run-base-ansible-devel:
             voting: false
+        - system-config-run-backup
         - system-config-run-dns
         - system-config-run-eavesdrop
         - system-config-run-lists
diff --git a/doc/source/sysadmin.rst b/doc/source/sysadmin.rst
index 136937b4d3..55547100d1 100644
--- a/doc/source/sysadmin.rst
+++ b/doc/source/sysadmin.rst
@@ -215,53 +215,21 @@ OpenStack CI infrastructure for another project.
 Backups
 =======
 
-Off-site backups are made to two servers:
+Infra uses the `bup <https://bup.github.io>`__ tool for backups.
 
-* backup01.ord.rax.ci.openstack.org
-* TBD
+Hosts in the ``backup`` Ansible inventory group will be backed up to
+servers in the ``backup-server`` group with ``bup``.  The
+``playbooks/roles/backup`` and ``playbooks/roles/backup-server`` roles
+implement the required setup.
 
-Puppet is used to perform the initial configuration of those machines,
-but to protect them from unauthorized access in case access to the
-puppet git repo is compromised, it is not run in agent or in cron mode
-on them.  Instead, it should be manually run when changes are made
-that should be applied to the backup servers.
+The backup server has a unique Unix user for each host to be backed
+up.  The roles will setup required users, their home directories in
+the backup volume and relevant ``authorized_keys``.
 
-To start backing up a server, some commands need to be run manually on
-both the backup server, and the server to be backed up.  On the server
-to be backed up::
-
-  sudo su -
-  ssh-keygen -t rsa -f /root/.ssh/id_rsa -N ""
-  bup init
-
-And then ``cat /root/.ssh/id_rsa.pub`` for use later.
-
-On the backup servers::
-
-  # add bup user
-  BUPUSER=bup-<short-servername>  # eg, bup-jenkins-dev
-  sudo useradd -r $BUPUSER -s /bin/bash -d /opt/backups/$BUPUSER -m
-  sudo su - $BUPUSER
-
-  # initalise bup
-  bup init
-
-  # should be in home directory /opt/backups/$BUPUSER
-  mkdir .ssh
-  cat >.ssh/authorized_keys
-
-write this into the authorized_keys file and end with ^D on a blank line::
-
-  command="BUP_DEBUG=0 BUP_FORCE_TTY=3 bup server",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty <ssh key from earlier>
-
-Switching back to the server to be backed up, run::
-
-  ssh $BUPUSER@backup01.ord.rax.ci.openstack.org
-
-And verify the host key.  Note this will start the bup server on the
-remote end, you will not be given a pty. Use ^D to close the connection
-cleanly.  Add the "backup" class in puppet to the server
-to be backed up.
+Host backup happens via a daily cron job (managed by Ansible) on each
+individual host to be backed up.  The host to be backed up initiates
+the backup process to the remote backup server(s) using a separate ssh
+key setup just for backup communication (see ``/root/.ssh/config``).
 
 Restore from Backup
 -------------------
@@ -276,9 +244,14 @@ how we restore content from backups::
   mkdir /root/backup-restore-$DATE
   cd /root/backup-restore-$DATE
 
+Root uses a separate ssh key and remote user to communicate with the
+backup server(s); the username and key to use for backup should be
+automatically configured in ``/root/.ssh/config``.  The backup server
+hostname can be taken from there.
+
 At this point we can join the tar that was split by the backup cron::
 
-  bup join -r bup-<short-servername>@backup01.ord.rax.ci.openstack.org: root > backup.tar
+  bup join -r backup.x.y.opendev.org: root > backup.tar
 
 At this point you may need to wait a while. These backups are stored on
 servers geographically distant from our normal servers resulting in less
diff --git a/playbooks/roles/backup-server/README.rst b/playbooks/roles/backup-server/README.rst
new file mode 100644
index 0000000000..c6560a0c64
--- /dev/null
+++ b/playbooks/roles/backup-server/README.rst
@@ -0,0 +1,15 @@
+Setup backup server
+
+This role configures backup server(s) in the ``backup-server`` group
+to accept backups from remote hosts.
+
+Note that the ``backup`` role must have run on each host in the
+``backup`` group before this role.  That role will create a
+``bup_user`` tuple in the hostvars for for each host consisting of the
+required username and public key.
+
+Each required user gets a separate home directory in ``/opt/backups``.
+Their ``authorized_keys`` file is configured with the public key to
+allow the remote host to log in and only run ``bup``.
+
+**Role Variables**
diff --git a/playbooks/roles/backup-server/defaults/main.yaml b/playbooks/roles/backup-server/defaults/main.yaml
new file mode 100644
index 0000000000..e5580b296a
--- /dev/null
+++ b/playbooks/roles/backup-server/defaults/main.yaml
@@ -0,0 +1 @@
+bup_users: []
\ No newline at end of file
diff --git a/playbooks/roles/backup-server/tasks/main.yaml b/playbooks/roles/backup-server/tasks/main.yaml
new file mode 100644
index 0000000000..f43b534834
--- /dev/null
+++ b/playbooks/roles/backup-server/tasks/main.yaml
@@ -0,0 +1,21 @@
+- name: Create backup directory
+  file:
+    state: directory
+    path: /opt/backups
+
+- name: Install bup
+  package:
+    name:
+      - bup
+    state: present
+
+- name: Build all bup users from backup hosts
+  set_fact:
+    bup_users: '{{ bup_users }} + [ {{ hostvars[item]["bup_user"] }} ]'
+  with_inventory_hostnames: backup
+
+- name: Create bup users
+  include_tasks: user.yaml
+  loop: '{{ bup_users }}'
+  loop_control:
+    loop_var: bup_user
\ No newline at end of file
diff --git a/playbooks/roles/backup-server/tasks/user.yaml b/playbooks/roles/backup-server/tasks/user.yaml
new file mode 100644
index 0000000000..24af08b211
--- /dev/null
+++ b/playbooks/roles/backup-server/tasks/user.yaml
@@ -0,0 +1,32 @@
+# note bup_user is the parent loop variable name; this works on each
+# element from the bup_users global.
+- name: Set variables
+  set_fact:
+    user_name: '{{ bup_user[0] }}'
+    user_key: '{{ bup_user[1] }}'
+
+- name: Create bup user
+  user:
+    name: '{{ user_name }}'
+    comment: 'Backup user'
+    shell: /bin/bash
+    home: '/opt/backups/{{ user_name }}'
+    create_home: yes
+  register: homedir
+
+- name: Create bup user authorized key
+  authorized_key:
+    user: '{{ user_name }}'
+    state: present
+    key: '{{ user_key }}'
+    key_options: 'command="BUP_DEBUG=0 BUP_FORCE_TTY=3 bup server",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty'
+
+# ansible-lint wants this in a handler, it should be done here and
+# now; this isn't like a service restart where multiple things might
+# call it.
+- name: Initalise bup  # noqa 503
+  shell: |
+    BUP_DIR=/opt/backups/{{ user_name }}/.bup bup init
+  become: yes
+  become_user: '{{ user_name }}'
+  when: homedir.changed
\ No newline at end of file
diff --git a/playbooks/roles/backup/README.rst b/playbooks/roles/backup/README.rst
new file mode 100644
index 0000000000..15cdcf254a
--- /dev/null
+++ b/playbooks/roles/backup/README.rst
@@ -0,0 +1,23 @@
+Configure a host to be backed up
+
+This role setups a host to use ``bup`` for backup to any hosts in the
+``backup-server`` group.
+
+A separate ssh key will be generated for root to connect to the backup
+server(s) and the host key for the backup servers will be accepted to
+the host.
+
+The ``bup`` tool is installed and a cron job is setup to run the
+backup periodically.
+
+Note the ``backup-server`` role must run after this to create the user
+correctly on the backup server.  This role sets a tuple ``bup_user``
+with the username and public key; the ``backup-server`` role uses this
+variable for each host in the ``backup`` group to initalise users.
+
+**Role Variables**
+
+.. zuul:rolevar:: bup_username
+
+   The username to connect to the backup server.  If this is left
+   undefined, it will be automatically set to ``bup-$(hostname)``
diff --git a/playbooks/roles/backup/files/bup-excludes b/playbooks/roles/backup/files/bup-excludes
new file mode 100644
index 0000000000..d5e0727e88
--- /dev/null
+++ b/playbooks/roles/backup/files/bup-excludes
@@ -0,0 +1,22 @@
+/proc/*
+/sys/*
+/dev/*
+/tmp/*
+/floppy/*
+/cdrom/*
+/var/spool/squid/*
+/var/spool/exim/*
+/media/*
+/mnt/*
+/var/agentx/*
+/run/*
+/root/backup-restore-*
+/root/.bup
+/etc/puppet/modules/*
+/etc/puppet/hieradata/*
+/var/cache/*
+/var/lib/puppet/reports/*
+/var/lib/postgresql/*
+/var/lib/lxcfs/*
+/opt/system-config/*
+/afs/*
diff --git a/playbooks/roles/backup/tasks/main.yaml b/playbooks/roles/backup/tasks/main.yaml
new file mode 100644
index 0000000000..91f4dca1d0
--- /dev/null
+++ b/playbooks/roles/backup/tasks/main.yaml
@@ -0,0 +1,61 @@
+- name: Generate bup username for this host
+  set_fact:
+    bup_username: 'bup-{{ inventory_hostname.split(".", 1)[0] }}'
+  when: bup_username is not defined
+
+- debug:
+    var: bup_username
+
+- name: Install bup
+  package:
+    name:
+      - bup
+    state: present
+
+- name: Generate keypair for backups
+  openssh_keypair:
+    path: /root/.ssh/id_backup_ed25519
+    type: ed25519
+  register: bup_keypair
+
+- name: Initalise bup # noqa 503
+  command: bup init
+  when: bup_keypair.changed
+
+- name: Configure ssh for backup server
+  blockinfile:
+    path: /root/.ssh/ssh_config
+    create: true
+    block: |
+      Host {{ item }}
+      HostName {{ item }}
+      IdentityFile /root/.ssh/id_backup_ed25519
+      User {{ bup_username }}
+    mode: 0600
+  with_inventory_hostnames: backup-server
+
+- name: Generate bup_user info tuple
+  set_fact:
+    bup_user: '{{ [ bup_username, bup_keypair["public_key"] ] }}'
+
+- name: Accept hostkey of backup server
+  known_hosts:
+    state: present
+    key: '{{ item }} ecdsa-sha2-nistp256 {{ hostvars[item]["ansible_ssh_host_key_ed25519_public"] }}'
+    name: '{{ item }}'
+  with_inventory_hostnames: backup-server
+
+- name: Write /etc/bup-excludes
+  copy:
+    src: bup-excludes
+    dest: /etc/bup-excludes
+    mode: 0444
+
+- name: Install backup cron job
+  cron:
+    name: "Run bup backup"
+    job: "tar -X /etc/bup-excludes -cPF - / | bup split -r {{ bup_username }}@{{ item }}: -n root -q"
+    user: root
+    hour: '5'
+    minute: '{{ 59|random(seed=item) }}'
+  with_inventory_hostnames: backup-server
\ No newline at end of file
diff --git a/playbooks/service-backup.yaml b/playbooks/service-backup.yaml
new file mode 100644
index 0000000000..2dfdcd40e3
--- /dev/null
+++ b/playbooks/service-backup.yaml
@@ -0,0 +1,10 @@
+# This needs to happen in order.  Backup hosts export their username/key
+# combos which are installed onto the backup server
+- hosts: "backup:!disabled"
+  name: "Base: Generate backup users and keys"
+  roles:
+    - backup
+- hosts: "backup-server:!disabled"
+  name: "Generate bup configuration"
+  roles:
+    - backup-server
diff --git a/playbooks/zuul/run-base.yaml b/playbooks/zuul/run-base.yaml
index 0d62619a40..40b3573cc5 100644
--- a/playbooks/zuul/run-base.yaml
+++ b/playbooks/zuul/run-base.yaml
@@ -87,6 +87,8 @@
         - host_vars/letsencrypt02.opendev.org.yaml
         - host_vars/mirror01.openafs.provider.opendev.org.yaml
         - host_vars/mirror-update01.opendev.org.yaml
+        - host_vars/backup-test01.opendev.org.yaml
+        - host_vars/backup-test02.opendev.org.yaml
     - name: Display group membership
       command: ansible localhost -m debug -a 'var=groups'
     - name: Run base.yaml
diff --git a/playbooks/zuul/templates/gate-groups.yaml.j2 b/playbooks/zuul/templates/gate-groups.yaml.j2
index bbf67fdac0..95b1ce6a5f 100644
--- a/playbooks/zuul/templates/gate-groups.yaml.j2
+++ b/playbooks/zuul/templates/gate-groups.yaml.j2
@@ -9,3 +9,10 @@ groups:
     - letsencrypt01.opendev.org
     - letsencrypt02.opendev.org
     - mirror01.openafs.provider.opendev.org
+
+  backup-server:
+    - backup01.region.provider.opendev.org
+
+  backup:
+    - backup-test01.opendev.org
+    - backup-test02.opendev.org
diff --git a/playbooks/zuul/templates/host_vars/backup-test01.opendev.org.yaml.j2 b/playbooks/zuul/templates/host_vars/backup-test01.opendev.org.yaml.j2
new file mode 100644
index 0000000000..3a9ccef467
--- /dev/null
+++ b/playbooks/zuul/templates/host_vars/backup-test01.opendev.org.yaml.j2
@@ -0,0 +1 @@
+bup_username: bup-backup01
\ No newline at end of file
diff --git a/playbooks/zuul/templates/host_vars/backup-test02.opendev.org.yaml.j2 b/playbooks/zuul/templates/host_vars/backup-test02.opendev.org.yaml.j2
new file mode 100644
index 0000000000..152cdee1e0
--- /dev/null
+++ b/playbooks/zuul/templates/host_vars/backup-test02.opendev.org.yaml.j2
@@ -0,0 +1,2 @@
+# Intentionally left blank to test autogeneration of name
+#bup_username: bup-backup-test02
\ No newline at end of file
diff --git a/testinfra/test_backups.py b/testinfra/test_backups.py
new file mode 100644
index 0000000000..d5b83d24f8
--- /dev/null
+++ b/testinfra/test_backups.py
@@ -0,0 +1,61 @@
+# Copyright 2019 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import os.path
+import pytest
+
+testinfra_hosts = ['backup01.region.provider.opendev.org',
+                   'backup-test01.opendev.org',
+                   'backup-test02.opendev.org']
+
+
+def test_bup_installed(host):
+    package = host.package("bup")
+    assert package.is_installed
+
+def test_server_users(host):
+    hostname = host.backend.get_hostname()
+    if hostname.startswith('backup-test'):
+        pytest.skip()
+
+    for username in 'bup-backup01', 'bup-backup-test02':
+        homedir = os.path.join('/opt/backups/', username)
+        bup_config = os.path.join(homedir, '.bup', 'config')
+        authorized_keys = os.path.join(homedir, '.ssh', 'authorized_keys')
+
+        user = host.user(username)
+        assert user.exists
+        assert user.home == homedir
+
+        f = host.file(authorized_keys)
+        assert f.exists
+        assert f.contains("ssh-ed25519")
+
+        f = host.file(bup_config)
+        assert f.exists
+
+def test_backup_host_config(host):
+    hostname = host.backend.get_hostname()
+    if hostname == 'backup01.region.provider.opendev.org':
+        pytest.skip()
+
+    f = host.file('/root/.ssh/id_backup_ed25519')
+    assert f.exists
+
+    f = host.file('/root/.ssh/ssh_config')
+    assert f.exists
+    assert f.contains('Host backup01.region.provider.opendev.org')
+
+    f = host.file('/root/.bup/config')
+    assert f.exists