FortiGate firewall backup automation with Ansible
A modular playbook that backs up multiple FortiGate devices dynamically, organizes backups, and applies automation best practices.
Modular playbook steps
- 1
Ensure the backup directory exists
Create the local backups folder once on the control node before fetching any configuration.
- 2
Pull the configuration backup
Use fortinet.fortios.fortios_monitor_fact with the system_config_backup selector to retrieve the full config over the HTTPS API.
- 3
Save the configuration to file
Write the raw config to a timestamped .conf file per device so every run is uniquely identifiable.
- 4
Document device details
Query system_status and store a per-device info file with site, role, version, model and serial number.
- 5
Apply retention
Find and remove .conf/.txt files older than retention_days so the backup store never grows unbounded.
- 6
Log and report
Append a SUCCESS/FAILED line to backup.log and print a clear per-device status to the console.
---
- name: Backup FortiGate Firewalls
hosts: fortigates
gather_facts: no
connection: httpapi
tasks:
- name: Ensure local backup directory exists
ansible.builtin.file:
path: "{{ backup_dir }}"
state: directory
mode: '0755'
run_once: true
delegate_to: localhost
- name: Backup FortiGate configuration
fortinet.fortios.fortios_monitor_fact:
selector: 'system_config_backup'
params:
scope: "{{ fortigate_backup_scope }}"
register: backup_result
ignore_errors: yes
- name: Save configuration to file
ansible.builtin.copy:
content: "{{ backup_result.meta.raw }}"
dest: "{{ backup_dir }}/{{ inventory_hostname }}_config_{{ backup_timestamp }}.conf"
mode: '0640'
delegate_to: localhost
register: save_result
when:
- backup_result is succeeded
- backup_result.meta.raw is defined
- name: Find old backup files
ansible.builtin.find:
paths: "{{ backup_dir }}"
patterns: ["*.conf", "*.txt"]
age: "{{ retention_days }}d"
register: old_backups
delegate_to: localhost
run_once: true
- name: Remove old backups
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_backups.files }}"
delegate_to: localhost
run_once: trueDevice variables
Variables are layered from global defaults down to per-device overrides, so the same playbook scales from one firewall to a whole fleet.
| backup_dir | ./backups |
| retention_days | 30 |
| backup_timestamp | date +%Y-%m-%d_%H%M%S (pipe lookup) |
| ansible_network_os | fortinet.fortios.fortios |
| ansible_connection | httpapi |
| ansible_httpapi_use_ssl | yes |
| ansible_httpapi_port | 443 |
| ansible_user / ansible_password | from FORTIGATE_USER / FORTIGATE_PASSWORD env |
| fortigate_backup_scope | global |
| ansible_host | 192.168.1.10 |
| fortigate_site | Headquarters - Main Data Center |
| fortigate_location | Amsterdam, Netherlands |
| fortigate_role | Primary Internet Gateway |
| fortigate_environment | production |
| retention_days (override) | 90 (optional, critical devices) |
Variable schema & validation rules
These rules are enforced by a zod schema (fortigate-backup-schema.ts). Credentials must be env lookups or Ansible Vault references โ never literal secrets.
| Variable | Layer | Req. | Rule |
|---|---|---|---|
| backup_dir | Global | โ | Path, no shell metacharacters; defaults to ./backups |
| retention_days | Global | โ | Integer 1โ3650; default 30 |
| backup_timestamp | Global | โ | Non-empty Jinja/pipe expression for unique filenames |
| backup_schedule | Global | โ | 5-field cron (m h dom mon dow); default 0 2 * * * |
| ansible_network_os | Group | Yes | Must equal fortinet.fortios.fortios |
| ansible_connection | Group | Yes | Must equal httpapi |
| ansible_httpapi_use_ssl | Group | โ | Boolean; must be true when port 443 |
| ansible_httpapi_port | Group | โ | Integer 1โ65535; default 443 |
| ansible_user | Group | Yes | Env lookup or Vault ref โ never a literal |
| ansible_password | Group | Yes | Env lookup or Vault ref โ never a literal secret |
| fortigate_backup_scope | Group | โ | Enum: global | vdom; default global |
| ansible_host | Host | Yes | Valid IPv4 address or hostname |
| fortigate_site | Host | Yes | Non-empty, โค120 chars |
| fortigate_environment | Host | โ | Enum: production | staging | lab | branch | dmz |
| retention_days (override) | Host | โ | Optional integer 1โ3650; overrides global |
Example variable files
Complete, schema-valid examples for each layer. Variable precedence flows global โ group โ host, so per-device files only set what differs.
---
# group_vars/all.yaml โ global defaults (Global layer)
backup_dir: "./backups" # safe path, no shell metacharacters
retention_days: 30 # integer 1โ3650
backup_timestamp: "{{ lookup('pipe', 'date +%Y-%m-%d_%H%M%S') }}"
backup_schedule: "0 2 * * *" # 5-field cron โ nightly at 02:00---
# group_vars/fortigates/vars.yaml โ connection + auth (Group layer)
ansible_network_os: fortinet.fortios.fortios
ansible_connection: httpapi
ansible_httpapi_use_ssl: true # required true when port 443
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443 # 1โ65535
ansible_command_timeout: 30 # 5โ600
ansible_httpapi_timeout: 30 # 5โ600
# Credentials: env lookup or Vault ref ONLY โ never a literal secret
ansible_user: "{{ lookup('env', 'FORTIGATE_USER') }}"
ansible_password: "{{ lookup('env', 'FORTIGATE_PASSWORD') }}"
# Vault alternative:
# ansible_user: "{{ vault_fortigate_user }}"
# ansible_password: "{{ vault_fortigate_password }}"
fortigate_backup_scope: global # global | vdom
fortigate_api_version: v2---
# host_vars/fw-prod-01.yaml โ per-device overrides (Host layer)
ansible_host: 192.168.1.10 # valid IPv4 or hostname
fortigate_site: "Headquarters - Main Data Center" # required, โค120 chars
fortigate_location: "Amsterdam, Netherlands"
fortigate_role: "Primary Internet Gateway"
fortigate_environment: production # production | staging | lab | branch | dmz
# Optional overrides (fall back to group/global when omitted)
retention_days: 90 # keep critical device backups longer
# ansible_httpapi_port: 8443---
# group_vars/fortigates/vault.yaml โ encrypted with: ansible-vault encrypt
vault_fortigate_user: "backup_svc"
vault_fortigate_password: "S3cure-Backup-Pass!"Vault references & credential resolution
Secrets live encrypted in vault.yaml and are referenced by name from plaintext var files. At runtime Ansible resolves each reference; host overrides win over group values, and literal secrets are rejected by the schema.
---
# group_vars/fortigates/vars.yaml โ reference the encrypted vault values
# (vars.yaml is plaintext; only vault.yaml is encrypted)
ansible_user: "{{ vault_fortigate_user }}"
ansible_password: "{{ vault_fortigate_password }}"
# Per-device override (host_vars/fw-dmz-01.yaml) โ separate vault entry
ansible_user: "{{ vault_dmz_admin_user }}"
ansible_password: "{{ vault_dmz_admin_password }}"# Create / edit the encrypted vault file
ansible-vault create group_vars/fortigates/vault.yaml
ansible-vault edit group_vars/fortigates/vault.yaml
# Run the backup, supplying the vault password
ansible-playbook forti_backup.yaml --ask-vault-pass
# ...or from a protected password file
ansible-playbook forti_backup.yaml --vault-password-file ~/.vault_pass
# Env-var alternative (no vault) โ schema also accepts these
export FORTIGATE_USER="backup_svc"
export FORTIGATE_PASSWORD="S3cure-Backup-Pass!"
ansible-playbook forti_backup.yaml| Source | Example | Resolves to |
|---|---|---|
| Vault reference | {{ vault_fortigate_password }} | Decrypted value from group_vars/fortigates/vault.yaml at runtime |
| Environment lookup | {{ lookup('env', 'FORTIGATE_PASSWORD') }} | Value of $FORTIGATE_PASSWORD in the control-node shell |
| Host override | host_vars/<device>.yaml ansible_user | Wins over group value for that one device (var precedence) |
| Literal secret | ansible_password: "Passw0rd!" | Rejected by schema โ use an env lookup or Vault ref instead |
Copy-ready Vault snippets
Paste the reference block into the plaintext var file and the matching block into the encrypted vault file for that layer. Use the Copy button on each block.
# group_vars/all.yaml โ global default credentials (all FortiGates)
ansible_user: "{{ vault_global_fortigate_user }}"
ansible_password: "{{ vault_global_fortigate_password }}"# group_vars/all/vault.yaml โ ansible-vault encrypt
vault_global_fortigate_user: "backup_svc"
vault_global_fortigate_password: "CHANGE-ME-global"# group_vars/fortigates/vars.yaml โ credentials for the fortigates group
ansible_user: "{{ vault_fortigate_user }}"
ansible_password: "{{ vault_fortigate_password }}"# group_vars/fortigates/vault.yaml โ ansible-vault encrypt
vault_fortigate_user: "backup_svc"
vault_fortigate_password: "CHANGE-ME-group"# host_vars/fw-dmz-01.yaml โ per-device override (wins over group)
ansible_user: "{{ vault_dmz_admin_user }}"
ansible_password: "{{ vault_dmz_admin_password }}"# host_vars/fw-dmz-01/vault.yaml โ ansible-vault encrypt
vault_dmz_admin_user: "dmz_backup_svc"
vault_dmz_admin_password: "CHANGE-ME-dmz"Inventory
---
all:
children:
fortigates:
children:
production_fortigates:
hosts:
fw-prod-01: { ansible_host: 192.168.1.10 }
fw-prod-02: { ansible_host: 192.168.1.11 }
branch_fortigates:
hosts:
fw-branch-01: { ansible_host: 192.168.2.10 }
fw-branch-02: { ansible_host: 192.168.3.10 }
dmz_fortigates:
hosts:
fw-dmz-01: { ansible_host: 10.0.1.10 }Run it
# Install prerequisites
pip3 install ansible fortiosapi
ansible-galaxy collection install -r requirements.yaml
# Set credentials
export FORTIGATE_USER="admin"
export FORTIGATE_PASSWORD="YourPassword"
# Backup all devices
ansible-playbook forti_backup.yaml
# Backup a single group or device
ansible-playbook forti_backup.yaml --limit production_fortigates
ansible-playbook forti_backup.yaml --limit fw-prod-01