Manual PBS configuration is fine for one node. The moment you're managing multiple Proxmox clusters, separate client environments, or need a teammate to reproduce your setup from scratch, the cracks appear fast. This post walks through structuring an Ansible project that provisions and manages Proxmox Backup Server — datastores, API tokens, retention policies, and offsite sync — as repeatable, auditable code.
Key Takeaways
- Ansible wraps the proxmox-backup-manager CLI in idempotent tasks — same result on every run
- Group vars define shared retention and scheduling defaults; host vars handle per-client overrides
- API tokens should be created through Ansible and stored in Vault, not copied into a spreadsheet
- Pruning schedules and GC cadence belong in version control, not clicked through the web UI
- Offsite sync jobs to remote-backups.com are provisioned the same way as any other PBS resource
Why Manual PBS Config Breaks at Scale
Three nodes in, manual configuration produces drift. Node A has a 30-day retention policy. Node B got updated last month and is on 60 days. Node C has an API token nobody can identify, from a client that left two years ago.
This isn't an edge case — it's what happens when configuration lives in browser tabs and memory.
The specific failure modes:
- Drift: Each node diverges quietly. There's no source of truth, so you only discover the divergence when something breaks.
- Undocumented tokens: Tokens created manually rarely get documented. When you rotate credentials, you find out the hard way which services depended on the old ones.
- Forgotten retention policies: One misconfigured datastore and you're either filling disks or losing more history than your clients expect. Both are bad at 2am.
- Onboarding friction: Provisioning a new client environment manually takes an hour per node. With Ansible, it takes one command.
Ansible addresses this with three properties: idempotency (run it 10 times, same result), version control (git diff your entire config), and repeatability (new node gets the exact same baseline as every other). None of that is exotic. It just requires putting in the structure upfront.
Inventory and Variable Structure
A clean inventory layout separates PBS hosts from the variables that configure them. For a multi-client or multi-node setup, this structure scales without becoming a maze:
inventory/
├── hosts.yml
├── group_vars/
│ ├── all.yml
│ └── pbs_servers.yml
└── host_vars/
├── pbs-node-01.yml
└── pbs-node-02.ymlgroup_vars/pbs_servers.yml holds defaults that apply to every PBS node: datastore base path, GC schedule, and your baseline retention policy.
pbs_datastore_base: /mnt/datastore
pbs_gc_schedule: "daily"
pbs_prune_schedule: "daily"
pbs_retention:
keep_last: 3
keep_daily: 14
keep_weekly: 8
keep_monthly: 6
keep_yearly: 1host_vars/pbs-node-02.yml overrides only what's different for a specific node or client. Everything else falls through to the group default.
# Client with a longer SLA — override retention only
pbs_retention:
keep_last: 5
keep_daily: 30
keep_weekly: 12
keep_monthly: 12
keep_yearly: 2hosts.yml defines the group and connection details:
all:
children:
pbs_servers:
hosts:
pbs-node-01:
ansible_host: 10.0.1.10
ansible_user: root
pbs-node-02:
ansible_host: 10.0.1.11
ansible_user: rootChange a retention default in group_vars once, and it propagates to every node on the next run. Per-client exceptions stay in host_vars, isolated from shared defaults.
Datastore Provisioning Playbook
Provisioning a Proxmox Backup Server datastore means: create the directory, register it with PBS via proxmox-backup-manager, then apply the pruning and GC schedule. All three steps in one playbook:
- name: Provision PBS datastore
hosts: pbs_servers
become: true
tasks:
- name: Create datastore directory
ansible.builtin.file:
path: "{{ pbs_datastore_base }}/{{ datastore_name }}"
state: directory
owner: backup
group: backup
mode: '0750'
- name: Register datastore with PBS
ansible.builtin.command:
cmd: >
proxmox-backup-manager datastore create {{ datastore_name }}
{{ pbs_datastore_base }}/{{ datastore_name }}
register: ds_create
failed_when: ds_create.rc != 0 and "already exists" not in ds_create.stderr
changed_when: ds_create.rc == 0
- name: Apply GC schedule
ansible.builtin.command:
cmd: >
proxmox-backup-manager datastore update {{ datastore_name }}
--gc-schedule "{{ pbs_gc_schedule }}"
changed_when: true
tags: [retention]
- name: Apply prune schedule
ansible.builtin.command:
cmd: >
proxmox-backup-manager datastore update {{ datastore_name }}
--prune-schedule "{{ pbs_prune_schedule }}"
changed_when: true
tags: [retention]Handle the 'already exists' exit code
proxmox-backup-manager returns a non-zero exit code when a resource already exists. Without the failed_when and changed_when conditions shown above, your playbook fails on every run after the first. Always check stderr for "already exists" when wrapping PBS CLI commands.
Pass datastore_name as an extra variable (-e datastore_name=client-a) or define it in each host_vars file for fully automated multi-client provisioning.
Token and ACL Management
Every client or service connecting to PBS needs an API token. Creating them manually means you end up with tokens named "test-token-FINAL-v2" and no record of what they're for or when they were created.
The better approach: create tokens through Ansible and store secrets in Ansible Vault immediately at creation time.
- name: Manage PBS API tokens
hosts: pbs_servers
become: true
vars_files:
- ../vault/pbs_tokens.yml
tasks:
- name: Create PBS user for client
ansible.builtin.command:
cmd: >
proxmox-backup-manager user create {{ pbs_client_user }}@pbs
--comment "{{ pbs_client_comment | default('Managed by Ansible') }}"
register: user_create
failed_when: user_create.rc != 0 and "already exists" not in user_create.stderr
changed_when: user_create.rc == 0
- name: Create API token with known secret
ansible.builtin.command:
cmd: >
proxmox-backup-manager user token create
{{ pbs_client_user }}@pbs {{ token_name }}
--token-secret "{{ vault_token_secret }}"
register: token_create
failed_when: token_create.rc != 0 and "already exists" not in token_create.stderr
changed_when: token_create.rc == 0
- name: Assign DatastoreBackup ACL
ansible.builtin.command:
cmd: >
proxmox-backup-manager acl update
/datastore/{{ datastore_name }}
--auth-id "{{ pbs_client_user }}@pbs!{{ token_name }}"
--role DatastoreBackup
changed_when: trueThe --token-secret flag lets you supply a pre-generated secret rather than capturing the one-time output from PBS. Pre-generate secrets with openssl rand -hex 32, store in vault/pbs_tokens.yml, then encrypt: ansible-vault encrypt vault/pbs_tokens.yml.
Roles: DatastoreBackup vs DatastoreReader
DatastoreBackup gives write and read access — right for clients sending backups. DatastoreReader gives read-only access — right for restore tokens and monitoring. Assign the minimum role needed. The offsite sync token only needs DatastoreReader on the source.
For MSPs, define pbs_client_user, token_name, and vault_token_secret in each host_vars file. Each client gets their own isolated user and token, with ACL scoped to their specific datastore.
Pruning and Retention as Code
Retention policy is among the most drift-prone parts of PBS. Clients have different SLAs, and without code, it's impossible to audit what's actually configured across nodes.
Define retention in variables and apply via datastore update. The playbook reuses the same task structure:
- name: Apply retention policy to datastore
hosts: pbs_servers
become: true
tasks:
- name: Set retention flags
ansible.builtin.command:
cmd: >
proxmox-backup-manager datastore update {{ datastore_name }}
--keep-last {{ pbs_retention.keep_last | default(omit) }}
--keep-daily {{ pbs_retention.keep_daily | default(omit) }}
--keep-weekly {{ pbs_retention.keep_weekly | default(omit) }}
--keep-monthly {{ pbs_retention.keep_monthly | default(omit) }}
--keep-yearly {{ pbs_retention.keep_yearly | default(omit) }}
changed_when: true
tags: [retention]Here's the full reference for PBS retention flags:
PBS Retention Flag Reference
| Flag | --keep-last | --keep-hourly | --keep-daily | --keep-weekly | --keep-monthly | --keep-yearly |
|---|---|---|---|---|---|---|
Description | Keep the N most recent snapshots | Keep one snapshot per clock-hour | Keep one snapshot per calendar day | Keep one snapshot per calendar week | Keep one snapshot per calendar month | Keep one snapshot per calendar year |
Example Value | 3 | 24 | 14 | 8 | 6 | 2 |
What It Keeps | Latest 3 snapshots, regardless of age | One per hour for the last 24 hours | One per day for the last 2 weeks | One per week for the last 8 weeks | One per month for the last 6 months | One per year for the last 2 years |
Flags combine. A policy of keep-last: 3, keep-daily: 14, keep-weekly: 8 keeps the three most recent snapshots, plus daily coverage for two weeks, plus weekly snapshots for two months. Snapshots matching any criterion are retained.
For MSPs: set tighter defaults in group_vars and only relax them in host_vars for clients with longer SLAs. This prevents accidental over-retention from quietly filling disks.
Offsite Sync to remote-backups.com
A complete infrastructure-as-code setup includes the offsite leg. remote-backups.com provides a hosted Proxmox Backup Server endpoint. You add it as a remote in PBS and create a sync job — the same way you'd configure any other PBS sync job.
First, add the remote. Store credentials in Vault:
- name: Configure offsite PBS sync
hosts: pbs_servers
become: true
vars_files:
- ../vault/remote_backups.yml
tasks:
- name: Add remote-backups.com as PBS remote
ansible.builtin.command:
cmd: >
proxmox-backup-manager remote create remote-backups
--url "https://pbs.remote-backups.com:8007"
--username "{{ vault_rb_username }}"
--password "{{ vault_rb_token_secret }}"
--fingerprint "{{ rb_server_fingerprint }}"
register: remote_create
failed_when: remote_create.rc != 0 and "already exists" not in remote_create.stderr
changed_when: remote_create.rc == 0
- name: Create daily offsite sync job
ansible.builtin.command:
cmd: >
proxmox-backup-manager sync-job create offsite-daily-{{ datastore_name }}
--store {{ datastore_name }}
--remote remote-backups
--remote-store {{ vault_rb_datastore }}
--schedule "daily"
--remove-vanished false
--encrypted-only true
register: sync_create
failed_when: sync_create.rc != 0 and "already exists" not in sync_create.stderr
changed_when: sync_create.rc == 0--remove-vanished false preserves backups on the remote even after local pruning removes them. This gives you independent retention history on the offsite copy. --encrypted-only true ensures no plaintext chunks leave your environment — only data encrypted with your client-side keys gets synced.
Always use --encrypted-only for external targets
Without this flag, PBS will sync any chunk — including unencrypted ones. If clients don't all use client-side encryption, this can expose plaintext backup data to the remote target. Enable client-side encryption at the PVE level first, then enforce it on the sync job.
Store vault_rb_username, vault_rb_token_secret, and vault_rb_datastore in vault/remote_backups.yml, encrypted with Ansible Vault. The fingerprint is not sensitive and goes in group_vars or host_vars.
Once these tasks run, every new PBS node you provision gets the offsite sync job configured identically. No manual steps, no nodes that get missed at 3am.
Testing and Idempotency
Before running playbooks against production PBS nodes, verify they behave the way you expect.
--check mode runs tasks without applying changes:
ansible-playbook playbooks/provision-datastores.yml \
-e datastore_name=test-ds \
--check --diffNot all tasks support check mode cleanly — ansible.builtin.command tasks with changed_when: true will always report changed. For real idempotency verification, run the playbook twice on a test node. The second run should report zero changes. If tasks still show changed, your changed_when conditions need work.
A cleaner alternative to error-string matching is querying datastore state before creating:
- name: Get existing datastores
ansible.builtin.command:
cmd: proxmox-backup-manager datastore list --output-format json
register: existing_datastores
changed_when: false
- name: Create datastore only if missing
ansible.builtin.command:
cmd: >
proxmox-backup-manager datastore create {{ datastore_name }}
{{ pbs_datastore_base }}/{{ datastore_name }}
when: >
datastore_name not in
(existing_datastores.stdout | from_json | map(attribute='name') | list)This makes the logic explicit rather than relying on exit code parsing. It's also safer: you won't accidentally skip a genuine failure because the error message happened to contain "already exists".
Tag tasks for targeted runs
Add tags to task blocks — tags: [datastores], tags: [tokens], tags: [retention]. This lets you run --tags retention when updating pruning policies without re-running the full provisioning stack. It speeds up day-to-day operations and reduces the blast radius of any individual change.
For MSPs managing multiple client environments: structure your playbooks so each client is a host_vars file. Adding a new client is copy the template, edit the variables, run the playbook. Every new client gets the full baseline — datastore, token, ACL, retention, offsite sync — in one pass.
Wrapping Up
Manual PBS configuration works until it doesn't. Past two or three nodes, undocumented state and configuration drift start costing real time. Ansible wraps proxmox-backup-manager in idempotent tasks, puts your retention policies and token management into version control, and makes onboarding a new PBS node a single command.
The patterns here — inventory structure, Vault for secrets, idempotent CLI wrapping — apply equally to local datastores and offsite sync targets. Once your PBS infrastructure is defined as code, you can audit it, hand it off to a teammate, and rebuild from scratch if you need to.
Need the offsite leg handled for you?
remote-backups.com provides encrypted PBS targets in EU datacenters — isolated credentials, no shared infrastructure, and full PBS compatibility. Provision it with Ansible on day one.
View Plans


