Group Policy Objects are at the heart of any robust Active Directory infrastructure. They define security settings, permissions, device configurations, and software restrictions. In the event of a disaster, human error, or simply a need to roll back after a change, not having a backup of your GPOs means facing hours of tedious reconstruction.

The graphical interface of the Group Policy Management Console does offer a backup feature, but it is manual, easy to overlook, and, most importantly, not versioned. The goal of this article is to automate this process cleanly using Ansible via WinRM, on an infrastructure comprising two separate Active Directory domains, with long-term archiving on a NAS and reporting via email.


Target Architecture

The infrastructure in question is based on:

  • Two independent AD domains, each with its own primary domain controller (PDC)
  • A Linux Ansible server that manages the entire infrastructure via scheduled playbooks
  • A TrueNAS Scale NAS serving as a long-term archive
  • An internal SMTP relay for notifications

The backup process consists of several steps:

  1. HTTPS WinRM connection (port 5986) on each PDC
  2. Execution of Backup-GPO and Get-GPOReport in PowerShell
  3. Transfer of files to the Ansible server
  4. Transfer to the NAS via rsync over SSH
  5. Deletion of backups older than one year
  6. Sending an HTML report via email

Prerequisites

Windows Side

WinRM must be enabled on the domain controllers. To verify:

winrm enumerate winrm/config/listener

If no listener is configured:

winrm quickconfig -quiet

The PowerShell GroupPolicy module must be present—it is installed natively on any server promoted to a domain controller.

Ansible service accounts must have Group Policy Creator Owners or Domain Admins permissions to run Backup-GPO.

Ansible Side

The pywinrm package must be installed on the Ansible server:

pip install pywinrm --break-system-packages

Service account passwords are stored in a dedicated Ansible Vault and are never transmitted in plain text.


The inventory

The inventory defines the two PDCs with their domain-specific WinRM connection settings. Note the use of ansible_winrm_scheme: https and port 5986 — WinRM over plain HTTP on port 5985 is not acceptable in a production environment, even an internal one.

---
all:
  children:
    pdc_domain1:
      hosts:
        PDC-DOM1:
          ansible_host: 192.168.X.X
          ansible_user: "domain1\\ansible_svc"
          ansible_password: "{{ vault_ansible_password_domain1 }}"
          ansible_connection: winrm
          ansible_winrm_transport: ntlm
          ansible_winrm_port: 5986
          ansible_winrm_scheme: https
          ansible_winrm_server_cert_validation: ignore
          domain_name: domain1.local

    pdc_domain2:
      hosts:
        PDC-DOM2:
          ansible_host: 192.168.X.X
          ansible_user: "domain2\\ansible_svc"
          ansible_password: "{{ vault_ansible_password_domain2 }}"
          ansible_connection: winrm
          ansible_winrm_transport: ntlm
          ansible_winrm_port: 5986
          ansible_winrm_scheme: https
          ansible_winrm_server_cert_validation: ignore
          domain_name: domain2.lan

The domain_name variable is a custom host variable—it will be reused in the playbook to name backup folders and customize log messages.


The Playbook

Play 1 — Backup on the PDCs

The first play runs on both host groups in parallel. Two PowerShell commands are executed:

  • Backup-GPO -All creates individual backups in subfolders identified by GUID — this is the native format, which can be restored directly using Restore-GPO
  • Get-GPOReport -ReportType XML generates a consolidated XML report of all GPOs in the domain, with their detailed settings — this file will be useful for future analysis
- name: "{{ domain_name }} - Back up all GPOs"
  ansible.windows.win_shell: |
    Import-Module GroupPolicy
    Backup-GPO -All -Path "C:\GPO_Backups\{{ domain_name }}"
    Get-GPOReport -All -ReportType XML `
      -Path "C:\GPO_Backups\{{ domain_name }}\all_gpo_report_{{ domain_name }}.xml"    
  register: gpo_result

The files are then transferred back to the Ansible server using the fetch module, into a dated temporary directory.

Play 1 — Transfer to the NAS

The transfer uses rsync via SSH with sshpass for password authentication (a dedicated SSH key would be more elegant, but the constraints of the TrueNAS Scale environment led to this solution — a topic for a future article). The --no-perms --no-owner --no-group flags prevent permission errors related to differences between Linux and ZFS file systems.

- name: "{{ domain_name }} - Copy to NAS via rsync"
  delegate_to: localhost
  shell: >
    SSHPASS='{{ vault_password_nas }}'
    sshpass -e rsync -avz --no-perms --no-owner --no-group --mkpath
    -e "ssh -p PORT_SSH -o StrictHostKeyChecking=no"
    /tmp/gpo_backups/{{ domain_name }}/{{ date_suffix }}/
    user@nas-host:/mnt/ARCHIVE/DC_BACKUP/{{ domain_name }}/{{ date_suffix }}/
  no_log: true

Files older than 365 days are purged directly on the NAS via SSH:

- name: "{{ domain_name }} - Purge backups older than 365 days"
  delegate_to: localhost
  shell: >
    SSHPASS='{{ vault_password_nas }}'
    sshpass -e ssh -p PORT_SSH -o StrictHostKeyChecking=no user@nas-host
    "find /mnt/ARCHIVE/DC_BACKUP/{{ domain_name }} -maxdepth 1 -type d -mtime +365 -exec rm -rf {} \;"
  ignore_errors: true
  no_log: true

Play 2 — HTML Report

A second play runs on localhost after the backups. It generates an HTML report by accessing the variables stored on each host via hostvars, then sends it via the internal SMTP relay.

The report displays the PowerShell backup status and the NAS transfer status for each domain, with visual indicators ✅ / 🔴 for quick reference.

- name: GPO Backup Report
  hosts: localhost
  gather_facts: yes
  tasks:
    - name: Send report via email
      ansible.builtin.mail:
        host: smtp.domain.local
        port: 25
        to: "admin@domaine.local"
        from: "ansible@serveur.domaine.local"
        subject: "GPO Backup Report - {{ ansible_date_time.date }}"
        body: "{{ recap_message }}"
        secure: never
        subtype: html

Backup Structure on the NAS

/mnt/ARCHIVE/DC_BACKUP/
├── domain1.local/
│   ├── 2026-03-31/
│   │   ├── all_gpo_report_domain1.local.xml   ← consolidated report
│   │   ├── {GUID-1}/                           ← individual GPO backup
│   │   │   ├── Backup.xml
│   │   │   ├── bkupInfo.xml
│   │   │   └── DomainSysvol/
│   │   └── {GUID-2}/
│   └── 2026-04-07/
└── domain2.lan/
    └── 2026-04-07/

Each GUID folder corresponds to an individual GPO, in the native Windows format—ready for use with Restore-GPO or the GPMC console without any prior manipulation.


Scheduling

The playbook is scheduled via cron every Monday morning, in line with the infrastructure’s other Ansible maintenance tasks (updates, network backups, NAS backups):

# GPO Backup — every Monday at 8:45 AM
45 8 * * 1 ansible-playbook /etc/ansible/playbooks/gpo-backup.yml \
  -i /etc/ansible/inventory-windows.yml \
  --vault-password-file /etc/ansible/vault/.vault_pass \
  -e "@/etc/ansible/vault/network-secrets.yml" \
  >> /var/log/ansible-gpo-backup.log 2>&1

What this backup does — and what it doesn’t do

This playbook produces backups that are natively restorable using Restore-GPO. In the event of accidental deletion of a GPO or incorrect modification, you can revert to the state of the last backup in just a few minutes.

The all_gpo_report_*.xml file offers additional value: it contains all the settings for each GPO in a structured XML format. This file enables qualitative analysis—detecting redundancies, obsolete settings, and empty or unlinked GPOs. We’ll cover this topic in a future article: loading this file into an analysis tool to audit the consistency of an AD domain.

What this playbook does not cover, however, is backing up SYSVOL (startup scripts, custom ADMX templates), which requires a complementary approach. For a complete recovery in the event of a major disaster, Veeam or an equivalent solution takes over for the entire DCs.


Conclusion

Automating GPO backups with Ansible and WinRM turns out to be fairly straightforward, provided you pay attention to three key points: WinRM authentication over HTTPS (not plain HTTP), proper naming of vault variables for service accounts, and managing PowerShell permissions on the Windows side.

The benefits of this approach over manual GUI backups are threefold: backups run regularly without human intervention, they are versioned by date on dedicated storage with long-term retention, and they integrate into the existing reporting workflow—a consolidated weekly email that provides an overview of the health of the infrastructure’s backups.

The next logical step is to analyze the contents of these backups. The XML report generated by Get-GPOReport is a goldmine of information—it’s just waiting to be leveraged.