Automate WireGuard VPN on AlmaLinux 10 With SaltStack

WireGuard VPN on AlmaLinux 10 with SaltStack: automate deployment with reusable Salt states, pillars, Docker, and wg-easy, plus firewall config across hosts.

In our previous guide, we manually set up a WireGuard VPN server with wg-easy on AlmaLinux 10. While that approach works great for a single server, what if you need to deploy WireGuard to multiple servers? Or ensure consistent configuration across your infrastructure? This is where SaltStack (Salt) comes in.

In this comprehensive guide, you’ll learn how to automate the entire WireGuard VPN setup using Salt configuration management, turning hours of manual work into a single command.

What You’ll Learn

  • Setting up Salt Master and Minions architecture
  • Creating reusable Salt states for WireGuard
  • Automating firewall configuration with Salt
  • Managing Docker and wg-easy deployment via Salt
  • Using Salt Pillars for secure configuration data
  • Deploying to multiple servers with one command
  • Version controlling your infrastructure configuration
  • Troubleshooting Salt state applications

Why Use SaltStack for WireGuard?

Benefits of Infrastructure as Code

Manual Setup Pain Points:

  • ❌ Time-consuming to deploy to multiple servers
  • ❌ Configuration drift between servers
  • ❌ No version control for server configurations
  • ❌ Difficult to audit what was changed and when
  • ❌ Manual errors during repetitive tasks
  • ❌ No easy rollback mechanism

SaltStack Advantages:

  • ✅ Deploy to 100 servers as easily as one
  • ✅ Consistent configuration across all servers
  • ✅ Git-based version control for all configs
  • ✅ Complete audit trail of changes
  • ✅ Automated, tested deployments
  • ✅ Easy rollback to previous configurations
  • ✅ Self-documenting infrastructure
  • ✅ Idempotent operations (safe to run multiple times)

Prerequisites

Before we begin, ensure you have:

  • Salt Master Server: A server running Salt Master (can be your existing infrastructure)
  • WireGuard Server(s): One or more AlmaLinux 10 servers as Salt Minions
  • Root access to all servers
  • Git repository for storing Salt configurations (recommended)
  • Basic understanding of YAML syntax
  • Familiarity with manual WireGuard setup

Architecture Overview

Our Salt infrastructure will consist of:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
┌─────────────────────┐
│   Salt Master       │
│  (Configuration)    │
│   - Salt States     │
│   - Pillar Data     │
│   - Git Repo        │
└──────────┬──────────┘
           │ (Salt Protocol: 4505, 4506)
    ┌──────┴─────────┬─────────────┐
    │                │             │
┌───▼────┐      ┌───▼────┐   ┌───▼────┐
│Minion 1│      │Minion 2│   │Minion 3│
│(VPN-1) │      │(VPN-2) │   │(VPN-3) │
└────────┘      └────────┘   └────────┘

Part 1: Setting Up Salt Infrastructure

Step 1: Install Salt Master

On your Salt Master server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Update system
sudo dnf update -y

# Add the official Salt Project repository
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo \
  | sudo tee /etc/yum.repos.d/salt.repo
sudo dnf clean expire-cache

# Install Salt Master
sudo dnf install -y salt-master

# Start and enable Salt Master
sudo systemctl enable --now salt-master

# Verify installation
salt --version

Don’t install Salt from EPEL on AlmaLinux 10. Older guides (including earlier versions of this one) start with dnf install -y epel-release and then pull salt-master from EPEL. That no longer works: EPEL ships the classic (non-onedir) Salt packages only up to EPEL 9, and there is no Salt package in EPEL 10 at all — the install simply fails. The salt.repo file above is the supported path and is what the Salt install guide publishes. It ships one section per release branch; 3008 LTS is the newest branch enabled by default, so that is what dnf installs. To hold a host on a different branch (3006 LTS or 3007 STS), edit /etc/yum.repos.d/salt.repo and enable only that section. Pin a specific build with dnf install -y salt-master-3008.2.

Configure Salt Master:

1
2
# Edit master configuration
sudo vim /etc/salt/master

Add or modify these settings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Accept all minion keys automatically (dev only - remove for production)
auto_accept: False

# File server backend
fileserver_backend:
  - roots
  - git

# Git fileserver configuration (if using Git)
gitfs_remotes:
  - https://github.com/your-org/salt-configs.git

# File roots - where Salt states are stored
file_roots:
  base:
    - /srv/salt

# Pillar roots - where sensitive data is stored
pillar_roots:
  base:
    - /srv/pillar
1
2
3
4
5
6
7
# Restart Salt Master
sudo systemctl restart salt-master

# Open firewall ports
sudo firewall-cmd --permanent --add-port=4505/tcp
sudo firewall-cmd --permanent --add-port=4506/tcp
sudo firewall-cmd --reload

Step 2: Install Salt Minion on WireGuard Server(s)

On each WireGuard server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Update system
sudo dnf update -y

# Add the official Salt Project repository (same file as on the master)
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo \
  | sudo tee /etc/yum.repos.d/salt.repo
sudo dnf clean expire-cache

# Install Salt Minion
sudo dnf install -y salt-minion

# Configure minion
sudo vim /etc/salt/minion

Keep the master and its minions on the same Salt release branch. A minion may run an older version than the master, but mixing 3006 and 3008 across a fleet is a good way to hit subtle state-rendering differences.

Add configuration:

1
2
3
4
5
6
7
8
# Salt Master IP or hostname
master: 192.168.1.100 # Replace with your Salt Master IP

# Unique minion ID
id: wireguard-srv

# Log level for troubleshooting
log_level: info
1
2
3
4
5
# Start and enable Salt Minion
sudo systemctl enable --now salt-minion

# Check minion status
sudo systemctl status salt-minion

Step 3: Accept Minion Keys

Back on the Salt Master:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# List pending minion keys
sudo salt-key -L

# Accept specific minion
sudo salt-key -a wireguard-srv

# Or accept all pending keys
sudo salt-key -A

# Verify minion is connected
sudo salt 'wireguard-srv' test.ping

Expected output:

1
wireguard-srv: True

Step 4: Create Salt Directory Structure

On the Salt Master, create the directory structure:

1
2
3
4
5
6
7
# Create base directories
sudo mkdir -p /srv/salt/{wireguard,firewall,hardening}
sudo mkdir -p /srv/pillar

# Set proper permissions
sudo chmod 755 /srv/salt
sudo chmod 700 /srv/pillar  # Pillar contains sensitive data

Part 2: Creating Salt States for WireGuard

Understanding Salt States

Salt States are YAML files that describe the desired state of your system. They’re:

  • Declarative: You describe what you want, not how to do it
  • Idempotent: Safe to run multiple times
  • Modular: Reusable across different servers

State 1: WireGuard Installation and Configuration

Create /srv/salt/wireguard/init.sls:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# WireGuard VPN Server Configuration
# Manages WireGuard installation, configuration, and peer management

# Install WireGuard and required packages
wireguard-packages:
  pkg.installed:
    - pkgs:
        - wireguard-tools
        - qrencode # For generating QR codes for mobile clients
    - reload_modules: True

# Enable IP forwarding (required for VPN routing)
enable-ip-forwarding-sysctl:
  sysctl.present:
    - name: net.ipv4.ip_forward
    - value: 1

enable-ipv6-forwarding-sysctl:
  sysctl.present:
    - name: net.ipv6.conf.all.forwarding
    - value: 1

# Enable source address validation mark (needed for WireGuard with fwmark)
enable-src-valid-mark-sysctl:
  sysctl.present:
    - name: net.ipv4.conf.all.src_valid_mark
    - value: 1

# Ensure /etc/wireguard directory exists with proper permissions
wireguard-config-dir:
  file.directory:
    - name: /etc/wireguard
    - user: root
    - group: root
    - mode: 700
    - require:
        - pkg: wireguard-packages

# Generate server private key if it doesn't exist
wireguard-server-privkey:
  cmd.run:
    - name: wg genkey > /etc/wireguard/server_private.key
    - unless: test -f /etc/wireguard/server_private.key
    - require:
        - file: wireguard-config-dir

# Set proper permissions on private key
wireguard-server-privkey-perms:
  file.managed:
    - name: /etc/wireguard/server_private.key
    - user: root
    - group: root
    - mode: 600
    - replace: False
    - require:
        - cmd: wireguard-server-privkey

# Generate server public key from private key
wireguard-server-pubkey:
  cmd.run:
    - name: cat /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
    - unless: test -f /etc/wireguard/server_public.key
    - require:
        - cmd: wireguard-server-privkey

# Create peers directory for storing peer configurations
wireguard-peers-dir:
  file.directory:
    - name: /etc/wireguard/peers
    - user: root
    - group: root
    - mode: 700
    - require:
        - file: wireguard-config-dir

# Create helper scripts directory
wireguard-scripts-dir:
  file.directory:
    - name: /etc/wireguard/scripts
    - user: root
    - group: root
    - mode: 755
    - require:
        - file: wireguard-config-dir

# Deploy WireGuard server configuration
wireguard-server-config:
  file.managed:
    - name: /etc/wireguard/wg0.conf
    - user: root
    - group: root
    - mode: 600
    - contents: |
        [Interface]
        # Server configuration
        Address = {{ salt['pillar.get']('wireguard:server:address', '10.99.0.1/24') }}
        ListenPort = {{ salt['pillar.get']('wireguard:server:port', 51820) }}
        PrivateKey = __PRIVATE_KEY__

        # No PostUp/PostDown firewall hooks here on purpose.
        # firewall/wireguard.sls already applies masquerading, the `wireguard`
        # zone and the wg-to-public policy with --permanent, so they survive
        # reboots and firewalld reloads. Re-adding them as interface hooks would
        # only give you two sets of rules fighting over the same traffic.

        # Peer configurations will be added below
        # Use /etc/wireguard/scripts/add-peer.sh to add new peers
    - require:
        - file: wireguard-config-dir
        - file: wireguard-server-privkey-perms

# Replace placeholder with actual private key
wireguard-inject-privkey:
  cmd.run:
    - name: sed -i "s|__PRIVATE_KEY__|$(cat /etc/wireguard/server_private.key)|" /etc/wireguard/wg0.conf
    - onchanges:
        - file: wireguard-server-config
    - require:
        - file: wireguard-server-config
        - file: wireguard-server-privkey-perms

# Deploy peer management script
wireguard-add-peer-script:
  file.managed:
    - name: /etc/wireguard/scripts/add-peer.sh
    - user: root
    - group: root
    - mode: 755
    - contents: |
        #!/bin/bash
        # WireGuard Peer Management Script
        # Usage: add-peer.sh <peer-name> [peer-ip]

        set -e

        if [ -z "$1" ]; then
            echo "Usage: $0 <peer-name> [peer-ip]"
            echo "Example: $0 john-laptop 10.99.0.10"
            exit 1
        fi

        PEER_NAME="$1"
        PEER_DIR="/etc/wireguard/peers"
        CONFIG_FILE="/etc/wireguard/wg0.conf"
        SERVER_PUBLIC_KEY=$(cat /etc/wireguard/server_public.key)
        SERVER_ENDPOINT="{{ salt['pillar.get']('wireguard:server:endpoint', '100.23.15.226') }}:{{ salt['pillar.get']('wireguard:server:port', 51820) }}"
        SERVER_ADDRESS="{{ salt['pillar.get']('wireguard:server:address', '10.99.0.1/24') }}"
        VPN_NETWORK="{{ salt['pillar.get']('wireguard:server:network', '10.99.0.0/24') }}"
        TUNNEL_MODE="{{ salt['pillar.get']('wireguard:server:tunnel_mode', 'full') }}"

        # Set AllowedIPs based on tunnel mode
        if [ "$TUNNEL_MODE" = "full" ]; then
            # Full tunnel: Route all traffic through VPN
            ALLOWED_IPS="0.0.0.0/0, ::/0"
        else
            # Split tunnel: Only route VPN network traffic
            ALLOWED_IPS="$VPN_NETWORK"
        fi

        # Auto-assign IP if not provided
        if [ -z "$2" ]; then
            # Find the next available IP
            LAST_IP=$(grep -oP 'AllowedIPs = \K[0-9.]+' "$CONFIG_FILE" | tail -1 | cut -d. -f4)
            if [ -z "$LAST_IP" ]; then
                NEXT_IP=10
            else
                NEXT_IP=$((LAST_IP + 1))
            fi
            PEER_IP="10.99.0.$NEXT_IP/32"
        else
            PEER_IP="$2/32"
        fi

        # Check if peer already exists
        if [ -d "$PEER_DIR/$PEER_NAME" ]; then
            echo "Error: Peer '$PEER_NAME' already exists!"
            exit 1
        fi

        # Create peer directory
        mkdir -p "$PEER_DIR/$PEER_NAME"

        # Generate peer keys
        wg genkey | tee "$PEER_DIR/$PEER_NAME/private.key" | wg pubkey > "$PEER_DIR/$PEER_NAME/public.key"
        chmod 600 "$PEER_DIR/$PEER_NAME/private.key"

        PEER_PRIVATE_KEY=$(cat "$PEER_DIR/$PEER_NAME/private.key")
        PEER_PUBLIC_KEY=$(cat "$PEER_DIR/$PEER_NAME/public.key")

        # Add peer to server configuration
        cat >> "$CONFIG_FILE" <<EOF

        # Peer: $PEER_NAME
        [Peer]
        PublicKey = $PEER_PUBLIC_KEY
        AllowedIPs = $PEER_IP
        EOF

        # Create client configuration
        cat > "$PEER_DIR/$PEER_NAME/client.conf" <<EOF
        [Interface]
        PrivateKey = $PEER_PRIVATE_KEY
        Address = ${PEER_IP%/*}/24
        DNS = 1.1.1.1, 8.8.8.8

        [Peer]
        PublicKey = $SERVER_PUBLIC_KEY
        Endpoint = $SERVER_ENDPOINT
        AllowedIPs = $ALLOWED_IPS
        PersistentKeepalive = 25
        EOF

        # Generate QR code for mobile clients
        qrencode -t ansiutf8 < "$PEER_DIR/$PEER_NAME/client.conf" > "$PEER_DIR/$PEER_NAME/qrcode.txt"
        qrencode -t png -o "$PEER_DIR/$PEER_NAME/qrcode.png" < "$PEER_DIR/$PEER_NAME/client.conf"

        # Restart WireGuard to apply changes
        systemctl restart wg-quick@wg0

        echo "✓ Peer '$PEER_NAME' created successfully!"
        echo ""
        echo "Peer IP: ${PEER_IP%/*}"
        echo "Tunnel Mode: $TUNNEL_MODE"
        if [ "$TUNNEL_MODE" = "full" ]; then
            echo "  → All traffic will be routed through VPN"
        else
            echo "  → Only VPN network ($VPN_NETWORK) will be routed through VPN"
        fi
        echo "Configuration saved to: $PEER_DIR/$PEER_NAME/client.conf"
        echo ""
        echo "QR Code (for mobile):"
        cat "$PEER_DIR/$PEER_NAME/qrcode.txt"
        echo ""
        echo "To provide configuration to client:"
        echo "  1. Copy the file: $PEER_DIR/$PEER_NAME/client.conf"
        echo "  2. Or scan the QR code: $PEER_DIR/$PEER_NAME/qrcode.png"
    - require:
        - file: wireguard-scripts-dir

# Deploy peer removal script
wireguard-remove-peer-script:
  file.managed:
    - name: /etc/wireguard/scripts/remove-peer.sh
    - user: root
    - group: root
    - mode: 755
    - contents: |
        #!/bin/bash
        # WireGuard Peer Removal Script
        # Usage: remove-peer.sh <peer-name>

        set -e

        if [ -z "$1" ]; then
            echo "Usage: $0 <peer-name>"
            exit 1
        fi

        PEER_NAME="$1"
        PEER_DIR="/etc/wireguard/peers"
        CONFIG_FILE="/etc/wireguard/wg0.conf"

        # Check if peer exists
        if [ ! -d "$PEER_DIR/$PEER_NAME" ]; then
            echo "Error: Peer '$PEER_NAME' does not exist!"
            exit 1
        fi

        # Get peer public key
        PEER_PUBLIC_KEY=$(cat "$PEER_DIR/$PEER_NAME/public.key")

        # Remove peer from WireGuard
        wg set wg0 peer "$PEER_PUBLIC_KEY" remove || true

        # Remove peer from config file
        sed -i "/# Peer: $PEER_NAME/,/^$/d" "$CONFIG_FILE"

        # Backup and remove peer directory
        mv "$PEER_DIR/$PEER_NAME" "$PEER_DIR/$PEER_NAME.removed-$(date +%Y%m%d-%H%M%S)"

        # Restart WireGuard
        systemctl restart wg-quick@wg0

        echo "✓ Peer '$PEER_NAME' removed successfully!"
    - require:
        - file: wireguard-scripts-dir

# Deploy peer listing script
wireguard-list-peers-script:
  file.managed:
    - name: /etc/wireguard/scripts/list-peers.sh
    - user: root
    - group: root
    - mode: 755
    - contents: |
        #!/bin/bash
        # WireGuard Peer Listing Script

        echo "=== WireGuard Peers ==="
        echo ""

        # Show active peers from wg command
        echo "Active Peers:"
        wg show wg0 peers 2>/dev/null | while read -r pubkey; do
            peer_name=$(grep -r "$pubkey" /etc/wireguard/peers/*/public.key 2>/dev/null | cut -d/ -f5)
            peer_ip=$(wg show wg0 allowed-ips | grep "$pubkey" | awk '{print $2}')
            last_handshake=$(wg show wg0 latest-handshakes | grep "$pubkey" | awk '{print $2}')

            if [ -n "$last_handshake" ] && [ "$last_handshake" != "0" ]; then
                last_seen=$(date -d @"$last_handshake" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$last_handshake" "+%Y-%m-%d %H:%M:%S")
            else
                last_seen="Never"
            fi

            echo "  - Name: ${peer_name:-Unknown}"
            echo "    IP: $peer_ip"
            echo "    Last Seen: $last_seen"
            echo ""
        done

        # Show configured peers
        echo "Configured Peers:"
        ls -1 /etc/wireguard/peers/ 2>/dev/null | while read -r peer; do
            if [ -f "/etc/wireguard/peers/$peer/public.key" ]; then
                peer_ip=$(grep "Address" "/etc/wireguard/peers/$peer/client.conf" 2>/dev/null | awk '{print $3}')
                echo "  - $peer ($peer_ip)"
            fi
        done
    - require:
        - file: wireguard-scripts-dir

# Enable and start WireGuard service
wireguard-service:
  service.running:
    - name: wg-quick@wg0
    - enable: True
    - require:
        - pkg: wireguard-packages
        - file: wireguard-server-config
        - cmd: wireguard-inject-privkey
        - sysctl: enable-ip-forwarding-sysctl
    - watch:
        - file: wireguard-server-config

Use salt['pillar.get'], not pillar.get. This is the single most expensive typo in Salt, and earlier versions of this guide had it. Inside a template, pillar is just a Python dictionary — so pillar.get('wireguard:server:port', 51820) calls plain dict.get(), looks for a literal key named wireguard:server:port, never finds one, and cheerfully returns the default. Colon-delimited traversal into nested pillar data is a feature of the execution module, which you reach as salt['pillar.get']('wireguard:server:port', 51820).

The failure mode is nasty precisely because nothing errors: your states render, the highstate goes green, and every value silently comes from the defaults baked into the template. You only notice when you change something in pillar, re-apply, and the server keeps using the old value. If you have Salt states in the wild, this is worth grepping for today:

1
grep -rn "{{ pillar\.get(" /srv/salt/

State 2: Firewall Configuration

Create /srv/salt/firewall/init.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Common firewall configuration for ALL minions
# This state provides the base firewall setup that every server needs

# Install firewalld package
firewalld:
  pkg.installed:
    - name: firewalld
    - reload_modules: True

# Ensure firewalld service is running and enabled
firewalld-service:
  service.running:
    - name: firewalld
    - enable: True
    - require:
        - pkg: firewalld

# Allow SSH (required for management of all servers)
allow-ssh:
  firewalld.present:
    - name: public
    - services:
        - ssh
    - require:
        - service: firewalld-service

# Reload firewall to apply common changes
firewall-reload-common:
  cmd.run:
    - name: firewall-cmd --reload
    - onchanges:
        - firewalld: allow-ssh

Create /srv/salt/firewall/wireguard.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# Firewall configuration for wireguard-srv
# This state adds ADDITIONAL rules beyond the common firewall (firewall/init.sls)

# Allow WireGuard UDP port
wireguard_allow_port:
  cmd.run:
    - name: firewall-cmd --permanent --add-port=51820/udp
    - unless: firewall-cmd --list-ports | grep -q "51820/udp"
    - require:
        - service: firewalld-service

# Enable masquerading for NAT (required for VPN traffic routing)
wireguard_enable_masquerade:
  cmd.run:
    - name: firewall-cmd --permanent --zone=public --add-masquerade
    - unless: firewall-cmd --zone=public --query-masquerade
    - require:
        - service: firewalld-service

# Give the tunnel its own zone
wireguard_zone:
  cmd.run:
    - name: firewall-cmd --permanent --new-zone=wireguard
    - unless: firewall-cmd --permanent --get-zones | grep -qw wireguard
    - require:
        - service: firewalld-service

wireguard_zone_interface:
  cmd.run:
    - name: firewall-cmd --permanent --zone=wireguard --add-interface=wg0
    - unless: firewall-cmd --permanent --zone=wireguard --query-interface=wg0
    - require:
        - cmd: wireguard_zone

# Allow the tunnel to forward OUT to the public zone. wg0 and the public NIC are
# in different zones, so this is INTER-zone forwarding and needs a policy object.
# (--add-forward would only cover intra-zone forwarding — see the note below.)
wireguard_policy:
  cmd.run:
    - name: firewall-cmd --permanent --new-policy=wg-to-public
    - unless: firewall-cmd --permanent --get-policies | grep -qw wg-to-public
    - require:
        - service: firewalld-service

wireguard_policy_config:
  cmd.run:
    - name: |
        firewall-cmd --permanent --policy=wg-to-public --add-ingress-zone=wireguard
        firewall-cmd --permanent --policy=wg-to-public --add-egress-zone=public
        firewall-cmd --permanent --policy=wg-to-public --set-target=ACCEPT
    - unless: firewall-cmd --permanent --policy=wg-to-public --get-target | grep -qw ACCEPT
    - require:
        - cmd: wireguard_policy
        - cmd: wireguard_zone

# Reload firewall to apply all WireGuard changes
wireguard_firewall_reload:
  cmd.run:
    - name: firewall-cmd --reload
    - onchanges:
        - cmd: wireguard_allow_port
        - cmd: wireguard_enable_masquerade
        - cmd: wireguard_zone_interface
        - cmd: wireguard_policy_config

Why a policy object instead of --direct FORWARD rules? Earlier versions of this guide used firewall-cmd --direct --add-rule ipv4 filter FORWARD 0 -i wg0 -j ACCEPT. Those rules do work, but firewalld’s direct interface has been deprecated since firewalld 1.0, and the states above are the supported replacement.

The trap when converting is reaching for --add-forward instead. That option enables intra-zone forwarding — between interfaces within one zone. A full-tunnel VPN is the opposite case: packets arrive on wg0 (the wireguard zone) and leave via your NIC (the public zone), which is inter-zone. A zone whose target is default will not forward across zones, so putting wg0 in internal and calling --add-forward reloads cleanly and still leaves clients with a tunnel and no internet. Inter-zone forwarding is what policy objects exist for.

The blunt alternative is to put wg0 in the built-in trusted zone, whose target is ACCEPT — that forwards to other zones too, but it also exposes every port on the VPN server itself to your VPN clients. The policy above does not.

State 3: wg-easy Web UI Deployment

Create /srv/salt/wireguard/wg-easy.sls:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# WireGuard Web UI (wg-easy) Configuration
# Provides a web-based interface for managing WireGuard peers

# Resolve pillar values once, up front. Doing the tunnel-mode branch here, in a
# Jinja variable, keeps conditional tags out of the compose file below — Jinja
# branching inside a YAML block scalar is an easy way to mangle your indentation.
{%- set wg = salt['pillar.get']('wireguard:server', {}) %}
{%- set ui = salt['pillar.get']('wireguard:ui', {}) %}
{%- set wg_endpoint = wg.get('endpoint', '100.23.15.226') %}
{%- set wg_port = wg.get('port', 51820) %}
{%- set wg_net = wg.get('network', '10.99.0.0/24') %}
{%- set wg_net6 = wg.get('network6', 'fdcc:ad94:bacf:61a3::/64') %}
{%- set wg_dns = wg.get('dns', '1.1.1.1,8.8.8.8') %}
{%- set ui_user = ui.get('username', 'admin') %}
{%- set ui_pass = ui.get('password', 'changeme123') %}
{%- set allowed_ips = '0.0.0.0/0,::/0' if wg.get('tunnel_mode', 'full') == 'full' else wg_net %}

# Add the Docker CE repository by writing the repo file directly.
# AlmaLinux 10 ships dnf5, where `dnf config-manager --add-repo <url>` no longer
# exists (it became `dnf config-manager addrepo --from-repofile=<url>`), so we
# skip the plugin entirely — curl works the same on dnf4 and dnf5 hosts.
docker-repo:
  cmd.run:
    - name: curl -fsSL https://download.docker.com/linux/rhel/docker-ce.repo -o /etc/yum.repos.d/docker-ce.repo
    - creates: /etc/yum.repos.d/docker-ce.repo

# Install Docker CE and related packages
docker-packages:
  pkg.installed:
    - pkgs:
        - docker-ce
        - docker-ce-cli
        - containerd.io
        - docker-buildx-plugin
        - docker-compose-plugin
    - require:
        - cmd: docker-repo

# Ensure Docker service is running.
#
# NOTE: there is deliberately no /etc/docker/daemon.json here. Earlier versions of
# this guide set "iptables": false on the theory that firewalld should own all
# networking. That only holds when every container runs with network_mode: host.
# wg-easy v15 runs on a BRIDGE network with published ports, and turning off
# Docker's firewall management breaks NAT and port publishing outright. On
# AlmaLinux 10 Docker's iptables commands are symlinked to iptables-nft, so its
# rules are translated into nftables for you — the default is correct as-is.
docker-service:
  service.running:
    - name: docker
    - enable: True
    - require:
        - pkg: docker-packages

# Create directory for wg-easy data
wg-easy-data-dir:
  file.directory:
    - name: /opt/wg-easy
    - user: root
    - group: root
    - mode: 755

# Deploy the compose file directly. No generator script, no bcrypt pre-hashing,
# no $-escaping: v15 takes its entire first-run configuration from INIT_*
# variables, which Salt renders straight from pillar.
wg-easy-compose:
  file.managed:
    - name: /opt/wg-easy/docker-compose.yml
    - user: root
    - group: root
    - mode: 600 # contains INIT_PASSWORD — keep it root-only
    - contents: |
        volumes:
          etc_wireguard:

        services:
          wg-easy:
            image: ghcr.io/wg-easy/wg-easy:15
            container_name: wg-easy
            environment:
              # Allow logging in over plain HTTP. Drop this and front the UI with
              # a TLS reverse proxy for anything you care about.
              - INSECURE=true
              # --- Unattended setup: read on FIRST START only ---
              - INIT_ENABLED=true
              - INIT_USERNAME={{ ui_user }}
              - INIT_PASSWORD={{ ui_pass }}
              - INIT_HOST={{ wg_endpoint }}
              - INIT_PORT={{ wg_port }}
              - INIT_DNS={{ wg_dns }}
              - INIT_IPV4_CIDR={{ wg_net }}
              - INIT_IPV6_CIDR={{ wg_net6 }}
              - INIT_ALLOWED_IPS={{ allowed_ips }}
            networks:
              wg:
                ipv4_address: 10.42.42.42
                ipv6_address: fdcc:ad94:bacf:61a3::2a
            volumes:
              - etc_wireguard:/etc/wireguard
              - /lib/modules:/lib/modules:ro
            ports:
              - "{{ wg_port }}:{{ wg_port }}/udp"
              - "51821:51821/tcp"
            restart: unless-stopped
            cap_add:
              - NET_ADMIN
              - SYS_MODULE
            sysctls:
              - net.ipv4.ip_forward=1
              - net.ipv4.conf.all.src_valid_mark=1
              - net.ipv6.conf.all.disable_ipv6=0
              - net.ipv6.conf.all.forwarding=1
              - net.ipv6.conf.default.forwarding=1

        networks:
          wg:
            driver: bridge
            enable_ipv6: true
            ipam:
              driver: default
              config:
                - subnet: 10.42.42.0/24
                - subnet: fdcc:ad94:bacf:61a3::/64
    - require:
        - file: wg-easy-data-dir

# Create systemd service for wg-easy
wg-easy-systemd:
  file.managed:
    - name: /etc/systemd/system/wg-easy.service
    - user: root
    - group: root
    - mode: 644
    - contents: |
        [Unit]
        Description=WireGuard Easy Web UI
        Requires=docker.service
        After=docker.service

        [Service]
        Type=oneshot
        RemainAfterExit=yes
        WorkingDirectory=/opt/wg-easy
        ExecStart=/usr/bin/docker compose up -d
        ExecStop=/usr/bin/docker compose down
        TimeoutStartSec=0

        [Install]
        WantedBy=multi-user.target
    - require:
        - file: wg-easy-compose

# Reload systemd to recognize new service
wg-easy-systemd-reload:
  cmd.run:
    - name: systemctl daemon-reload
    - onchanges:
        - file: wg-easy-systemd

# Allow wg-easy web UI port through firewall
wg-easy-firewall-port:
  cmd.run:
    - name: firewall-cmd --permanent --add-port=51821/tcp
    - unless: firewall-cmd --list-ports | grep -q "51821/tcp"
    - require:
        - service: firewalld-service

# Reload firewall
wg-easy-firewall-reload:
  cmd.run:
    - name: firewall-cmd --reload
    - onchanges:
        - cmd: wg-easy-firewall-port

# Start wg-easy service
wg-easy-service:
  service.running:
    - name: wg-easy
    - enable: True
    - require:
        - service: docker-service
        - file: wg-easy-compose
        - file: wg-easy-systemd
        - cmd: wg-easy-systemd-reload
    - watch:
        - file: wg-easy-compose
        - file: wg-easy-systemd

Four things in that state are worth calling out, because they are the parts most likely to bite you.

This targets wg-easy v15, and v15 is a different program. The rewrite threw out the configuration model every older wg-easy tutorial depends on: PASSWORD_HASH (and the wgpw command that produced it), WG_HOST, WG_PORT, WG_DEFAULT_ADDRESS, WG_ALLOWED_IPS and the rest are gone. They are not errors — they are silently ignored, which is a much worse way to fail. Config now lives in a SQLite database under /etc/wireguard, and the container runs on a bridge network with published ports instead of network_mode: host.

INIT_* is what makes v15 automatable at all. At first glance the rewrite looks hostile to config management: you’re supposed to click through a setup wizard in a browser. But upstream ships an unattended setup path for exactly this case, and it is a straight improvement over v14 — Salt renders INIT_USERNAME, INIT_PASSWORD, INIT_HOST, INIT_PORT and the CIDRs directly from pillar, and the whole generate-compose.sh dance (run a container just to bcrypt a password, then double every $ so Compose doesn’t eat it) disappears. Two rules come with it:

  • The variables are read only on first start. Once the SQLite DB exists, wg-easy ignores them. Changing INIT_PASSWORD in pillar and re-running state.apply will rewrite docker-compose.yml and restart the container, but it will not change the admin password — do that in the UI, or wipe the volume and re-init.
  • They must be set as a group. INIT_USERNAME, INIT_PASSWORD, INIT_HOST and INIT_PORT go together, and INIT_IPV4_CIDR requires INIT_IPV6_CIDR. Set half a group and setup won’t be skipped. Upstream also recommends removing the variables once setup is done, since the password sits in plain text in the compose file — which is why the file.managed above is mode 600.

Do not set "iptables": false in daemon.json. Plenty of AlmaLinux guides tell you to, on the grounds that the host is pure nftables. That advice is only sound when every container uses network_mode: host, which was true for v14 and is not true here. v15 publishes ports on a bridge network, and disabling Docker’s firewall management breaks NAT and port publishing. Docker’s iptables binary is symlinked to iptables-nft on AlmaLinux 10, so its rules land in nftables anyway. Leave the default alone — this state ships no daemon.json at all.

wg-easy replaces the wireguard state; it does not complement it. See the top file in Part 4.

Part 3: Salt Pillar Configuration

Pillar data stores sensitive configuration separate from states.

Create Pillar Data

Create /srv/pillar/common.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Common pillar data shared across all minions

# WireGuard VPN Configuration
wireguard:
  server:
    # VPN network address for the server
    address: "10.99.0.1/24"

    # UDP port for WireGuard (default: 51820)
    port: 51820

    # Public endpoint that clients will connect to
    # This should be your server's public IP or domain
    endpoint: "100.23.15.226"

    # VPN network CIDR (used by clients to route traffic through VPN)
    network: "10.99.0.0/24"

    # IPv6 VPN network. wg-easy v15 requires this whenever INIT_IPV4_CIDR is set —
    # the two belong to the same INIT_* group and must be supplied together.
    network6: "fdcc:ad94:bacf:61a3::/64"

    # DNS servers pushed to clients
    dns: "1.1.1.1,8.8.8.8"

    # Tunnel mode: 'full' or 'split'
    # - 'full': Route ALL traffic through VPN (client appears to have VPN server's public IP)
    # - 'split': Only route VPN network traffic (10.99.0.0/24) through VPN
    tunnel_mode: "full"

  # Web UI Configuration (wg-easy)
  # Used ONLY by the wireguard.wg-easy state, and ONLY on the container's first
  # start — see Part 2, State 3. Changing these later does not rotate the admin
  # credentials; do that in the UI.
  ui:
    username: "admin"
    # ⚠️  SECURITY: Change this before deploying, and keep it in an encrypted
    # pillar (GPG renderer) rather than in plain text — it is written verbatim
    # into /opt/wg-easy/docker-compose.yml.
    password: "YourSecurePasswordHere123!"
    # Web UI will be accessible at: http://100.23.15.226:51821

Create /srv/pillar/top.sls:

1
2
3
4
base:
  # Common pillar data for all minions
  "*":
    - common

Refresh Pillar Data

1
2
3
4
5
# Refresh pillar data on all minions
sudo salt '*' saltutil.refresh_pillar

# Verify pillar data
sudo salt 'wireguard-srv' pillar.get wireguard

Part 4: Salt Top File Configuration

The top file tells Salt which states to apply to which minions.

Create /srv/salt/top.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
base:
  # Apply base firewall to ALL minions
  "*":
    - firewall

  # WireGuard VPN server
  # Gets common firewall from '*', plus WireGuard installation and configuration
  "wireguard-srv":
    - wireguard # WireGuard VPN server setup and peer management
    - firewall.wireguard # WireGuard-specific firewall rules

Pick one: wireguard or wireguard.wg-easy

These two states are alternatives, not layers — assigning both to the same minion will break the deployment, and it is worth being explicit about why.

The wireguard state runs WireGuard natively on the host: wg-quick@wg0 owns the wg0 interface and binds UDP 51820. The wireguard.wg-easy state runs WireGuard inside a container, which creates its own wg0 in the container’s network namespace and publishes UDP 51820 on the host. Both want the same host port, so whichever starts second fails to bind. They also disagree about who owns peer state: the wireguard state manages peers through /etc/wireguard/wg0.conf and the shell scripts from State 1, while wg-easy keeps peers in its own SQLite database and knows nothing about that file.

So choose the model that fits:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
base:
  "*":
    - firewall

  # Model A — host-native WireGuard, peers managed by Salt + shell scripts.
  # CLI-driven, no web UI, everything is a file you can put in Git.
  "wireguard-srv":
    - wireguard
    - firewall.wireguard

  # Model B — wg-easy, peers managed through the web UI (or its API).
  # Salt provisions Docker and does an unattended first-run setup; day-2 peer
  # management happens in the UI, not in pillar.
  #
  # "wireguard-ui-srv":
  #   - wireguard.wg-easy
  #   - firewall.wireguard

Model B still wants firewall.wireguard for the masquerade rules and the open UDP port, but not the host wg0 zone assignment — the container’s wg0 never appears on the host, so that part is simply inert.

Everything from Part 5 onward assumes Model A. If you’re running Model B, the peer scripts (add-peer.sh, remove-peer.sh, list-peers.sh) and the custom execution module in Part 7 don’t apply — wg-easy owns peers, and you’d drive it through its API instead.

Part 5: Deploying WireGuard with Salt

Now comes the magic moment - deploying everything with a single command!

Test Configuration First (Dry-Run)

Always test before applying:

1
2
3
4
5
# Test all minions (dry-run)
sudo salt '*' state.apply test=True

# Test specific minion
sudo salt 'wireguard-srv' state.apply test=True

This shows what would change without actually changing anything.

Apply Configuration

1
2
3
4
5
# Apply to all minions
sudo salt '*' state.apply

# Or apply to specific minion
sudo salt 'wireguard-srv' state.apply

Expected output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
wireguard-srv:
----------
          ID: wireguard-packages
    Function: pkg.installed
      Result: True
     Comment: All specified packages are already installed
----------
          ID: wireguard-service
    Function: service.running
        Name: wg-quick@wg0
      Result: True
     Comment: Service wg-quick@wg0 is already enabled, and is running
----------

Summary for wireguard-srv
-------------
Succeeded: 45 (changed=20)
Failed:     0
-------------
Total states run:     45
Total run time:   45.123 s

Verify Deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Check WireGuard is running
sudo salt 'wireguard-srv' cmd.run 'systemctl status wg-quick@wg0 --no-pager'

# Check WireGuard interface
sudo salt 'wireguard-srv' cmd.run 'wg show'

# Check firewall rules
sudo salt 'wireguard-srv' cmd.run 'firewall-cmd --list-all'

# Check wg-easy container (if deployed)
sudo salt 'wireguard-srv' cmd.run 'docker ps | grep wg-easy'

# Get server public key
sudo salt 'wireguard-srv' cmd.run 'cat /etc/wireguard/server_public.key'

Part 6: Managing Configuration Changes

Making Changes to Configuration

Change Tunnel Mode

Edit /srv/pillar/common.sls:

1
2
3
wireguard:
  server:
    tunnel_mode: "split" # Changed from 'full' to 'split'

Apply changes:

1
2
3
4
5
6
7
8
# Refresh pillar
sudo salt 'wireguard-srv' saltutil.refresh_pillar

# Reapply states
sudo salt 'wireguard-srv' state.apply

# Regenerate peer configs (if needed)
sudo salt 'wireguard-srv' cmd.run 'cd /etc/wireguard/scripts && ./add-peer.sh new-client'

Change VPN Network

Edit /srv/pillar/common.sls:

1
2
3
4
wireguard:
  server:
    address: "10.88.0.1/24"
    network: "10.88.0.0/24"

Apply and restart:

1
2
3
sudo salt 'wireguard-srv' saltutil.refresh_pillar
sudo salt 'wireguard-srv' state.apply
sudo salt 'wireguard-srv' cmd.run 'systemctl restart wg-quick@wg0'

Version Control with Git

Store your Salt configurations in Git:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Initialize Git repository
cd /srv/salt
git init
git add .
git commit -m "Initial WireGuard Salt configuration"

# Add remote (GitHub, GitLab, etc.)
git remote add origin [email protected]:your-org/salt-wireguard.git
git push -u origin main

# After making changes
git add .
git commit -m "Change tunnel mode to split tunnel"
git push

# On Salt Master, pull changes
git pull origin main

# Apply to minions
sudo salt '*' state.apply

Part 7: Advanced Salt Techniques

Targeting Specific Minions

Salt supports sophisticated targeting:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# By minion ID
sudo salt 'wireguard-*' state.apply

# By grain (OS, CPU, etc.)
sudo salt -G 'os:AlmaLinux' state.apply

# By pillar data
sudo salt -I 'wireguard:server:tunnel_mode:full' state.apply

# Compound matching
sudo salt -C 'G@os:AlmaLinux and wireguard-*' state.apply

Using Salt Orchestration

Create /srv/salt/orchestrate/deploy-wireguard.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Orchestration: Deploy WireGuard to multiple servers in order

# Step 1: Update all minions
update_system:
  salt.state:
    - tgt: "wireguard-*"
    - sls:
        - system.updates

# Step 2: Deploy base firewall
deploy_firewall:
  salt.state:
    - tgt: "wireguard-*"
    - sls:
        - firewall
    - require:
        - salt: update_system

# Step 3: Deploy WireGuard
deploy_wireguard:
  salt.state:
    - tgt: "wireguard-*"
    - sls:
        - wireguard
        - firewall.wireguard
    - require:
        - salt: deploy_firewall

# Step 4: Verify deployment
verify_deployment:
  salt.function:
    - name: cmd.run
    - tgt: "wireguard-*"
    - arg:
        - wg show
    - require:
        - salt: deploy_wireguard

Run orchestration:

1
sudo salt-run state.orchestrate orchestrate.deploy-wireguard

This orchestrates Model A hosts. There is deliberately no wireguard.wg-easy step in the chain — as Part 4 explains, wg-easy is an alternative to the host-native wireguard state, not a layer on top of it, so applying both to the same target would leave two processes fighting over UDP 51820. If you run wg-easy hosts, give them their own target (vpn-ui-*) and their own orchestration.

Salt Reactor for Auto-Healing

Create /srv/salt/reactor/wireguard-monitor.sls:

1
2
3
4
5
6
# Restart WireGuard if it fails
restart_wireguard:
  local.cmd.run:
    - tgt: {{ data['id'] }}
    - arg:
      - systemctl restart wg-quick@wg0

Configure reactor in /etc/salt/master:

1
2
3
reactor:
  - "salt/service/wg-quick@wg0/stopped":
      - /srv/salt/reactor/wireguard-monitor.sls

Custom Salt Modules

Create /srv/salt/_modules/wireguard.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/usr/bin/env python3
"""
Custom Salt module for WireGuard management
"""

import subprocess


def add_peer(name, ip=None):
    """
    Add a WireGuard peer

    CLI Example:
        salt 'wireguard-srv' wireguard.add_peer john-laptop
    """
    cmd = ['/etc/wireguard/scripts/add-peer.sh', name]
    if ip:
        cmd.append(ip)

    result = subprocess.run(cmd, capture_output=True, text=True)
    return {
        'success': result.returncode == 0,
        'stdout': result.stdout,
        'stderr': result.stderr
    }


def list_peers():
    """
    List all WireGuard peers

    CLI Example:
        salt 'wireguard-srv' wireguard.list_peers
    """
    result = subprocess.run(
        ['/etc/wireguard/scripts/list-peers.sh'],
        capture_output=True,
        text=True
    )
    return result.stdout


def remove_peer(name):
    """
    Remove a WireGuard peer

    CLI Example:
        salt 'wireguard-srv' wireguard.remove_peer john-laptop
    """
    result = subprocess.run(
        ['/etc/wireguard/scripts/remove-peer.sh', name],
        capture_output=True,
        text=True
    )
    return {
        'success': result.returncode == 0,
        'stdout': result.stdout,
        'stderr': result.stderr
    }

Sync modules to minions:

1
2
3
4
5
sudo salt '*' saltutil.sync_modules

# Use custom module
sudo salt 'wireguard-srv' wireguard.add_peer john-laptop
sudo salt 'wireguard-srv' wireguard.list_peers

Part 8: Monitoring and Maintenance

Salt-Based Monitoring

Create /srv/salt/monitoring/wireguard-check.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Monitoring state - checks WireGuard health

wireguard_health_check:
  cmd.run:
    - name: |
        echo "=== WireGuard Health Check ==="
        echo "Service Status:"
        systemctl is-active wg-quick@wg0

        echo ""
        echo "Interface Status:"
        wg show wg0

        echo ""
        echo "Firewall Rules:"
        firewall-cmd --list-ports | grep 51820

        echo ""
        echo "Active Connections:"
        wg show wg0 latest-handshakes | wc -l

Run health check:

1
sudo salt 'wireguard-srv' state.sls monitoring.wireguard-check

Automated Backups with Salt

Create /srv/salt/backup/wireguard.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Backup WireGuard configuration

wireguard_backup:
  cmd.run:
    - name: |
        BACKUP_DIR="/root/wireguard-backups"
        DATE=$(date +%Y%m%d-%H%M%S)
        mkdir -p $BACKUP_DIR
        tar -czf $BACKUP_DIR/wireguard-$DATE.tar.gz \
          /etc/wireguard/ \
          /opt/wg-easy/ \
          /etc/docker/daemon.json
        # Keep only last 7 backups
        cd $BACKUP_DIR && ls -t | tail -n +8 | xargs -r rm
        echo "Backup created: wireguard-$DATE.tar.gz"

Schedule backups:

1
2
3
4
5
# Add to Salt scheduler
sudo salt 'wireguard-srv' schedule.add wireguard_backup \
  function='state.sls' \
  job_args='["backup.wireguard"]' \
  seconds=86400  # Daily

Part 9: Troubleshooting Salt Deployments

Common Salt Issues

Issue 1: Minion Can’t Connect to Master

Symptoms: salt-key -L doesn’t show minion.

Solution:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# On minion, check logs
sudo journalctl -u salt-minion -f

# Common fixes:
# 1. Check firewall on master
sudo firewall-cmd --list-ports | grep -E "4505|4506"

# 2. Verify minion config
sudo cat /etc/salt/minion | grep master

# 3. Restart minion
sudo systemctl restart salt-minion

# 4. Check connectivity (telnet is not installed by default on AlmaLinux 10)
nc -zv <master-ip> 4505
nc -zv <master-ip> 4506

Issue 2: State Application Fails

Symptoms: State returns Failed: X.

Solution:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# View detailed output
sudo salt 'wireguard-srv' state.apply -l debug

# Check specific state
sudo salt 'wireguard-srv' state.sls wireguard test=True

# Verify pillar data
sudo salt 'wireguard-srv' pillar.get wireguard

# Check for syntax errors in states
python3 -c "import yaml; yaml.safe_load(open('/srv/salt/wireguard/init.sls'))"

Issue 3: Pillar Data Not Updating

Symptoms: Changes to pillar not reflected.

Solution:

1
2
3
4
5
6
7
8
# Refresh pillar on minions
sudo salt '*' saltutil.refresh_pillar

# Clear pillar cache
sudo salt '*' saltutil.clear_cache

# Verify pillar gets updated
sudo salt 'wireguard-srv' pillar.items

Issue 4: Docker State Fails on AlmaLinux 10

Symptoms: Docker won’t start or iptables errors.

Solution:

Ensure /etc/docker/daemon.json has:

1
2
3
4
5
{
  "iptables": false,
  "ip6tables": false,
  "storage-driver": "overlay2"
}

Then:

1
2
sudo salt 'wireguard-srv' service.restart docker
sudo salt 'wireguard-srv' cmd.run 'docker ps'

Salt Best Practices

  1. Always test first: Use test=True before applying
  2. Use version control: Store states in Git
  3. Modular states: Keep states small and focused
  4. Use pillars for sensitive data: Never hardcode passwords in states
  5. Document your states: Add comments explaining what and why
  6. Use requisites: Ensure proper ordering with require, watch, onchanges
  7. Idempotent states: States should be safe to run multiple times
  8. Monitor state runs: Set up logging and alerting

Part 10: Scaling to Multiple VPN Servers

One of Salt’s biggest advantages is easy scaling.

Deploy to Multiple Servers

Target multiple minions with patterns:

1
2
3
4
5
6
7
8
# Deploy to all VPN servers
sudo salt 'vpn-*' state.apply

# Deploy to servers in specific datacenter (using grains)
sudo salt -G 'datacenter:us-east' state.apply

# Deploy in batches (10 at a time)
sudo salt 'vpn-*' state.apply --batch-size 10

Load Balancing with Salt

Create /srv/pillar/vpn-servers.sls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
vpn_servers:
  vpn-us-east-1:
    endpoint: "100.23.15.226"
    network: "10.99.0.0/24"
  vpn-us-west-1:
    endpoint: "198.51.100.50"
    network: "10.98.0.0/24"
  vpn-eu-central-1:
    endpoint: "203.0.113.75"
    network: "10.97.0.0/24"

Update states to use per-server pillar data:

1
2
# In wireguard/init.sls
Address = {{ salt['pillar.get']('vpn_servers:' + grains['id'] + ':network').split('/')[0] }}/24

Conclusion

Congratulations! You’ve mastered automating WireGuard VPN deployment with SaltStack. You can now:

Deploy VPN servers with a single commandMaintain consistent configuration across multiple serversVersion control your infrastructureScale to hundreds of VPN servers effortlesslyImplement automated monitoring and backupsQuickly respond to security updatesReduce human error in deployment

Key Advantages Recap

Manual Setup:

  • ⏱️ 1-2 hours per server
  • 🔄 Prone to configuration drift
  • 📝 No audit trail
  • 🚫 Difficult to scale

Salt Automation:

  • ⏱️ 5-10 minutes for any number of servers
  • ✅ Guaranteed consistency
  • 📜 Complete version history
  • 🚀 Infinitely scalable

Next Steps

  1. Set up GitLab/GitHub CI/CD for automated testing of Salt states
  2. Implement Salt monitoring with Prometheus and Grafana
  3. Create custom execution modules for complex WireGuard operations
  4. Set up Salt reactors for auto-healing
  5. Explore Salt Cloud for automated cloud provisioning
  6. Integrate with your existing configuration management workflow

Additional Resources

Complete Repository Structure

Here’s the final directory structure for your Salt repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/srv/salt/
├── top.sls
├── wireguard/
│   ├── init.sls
│   └── wg-easy.sls
├── firewall/
│   ├── init.sls
│   └── wireguard.sls
├── monitoring/
│   └── wireguard-check.sls
├── backup/
│   └── wireguard.sls
└── orchestrate/
    └── deploy-wireguard.sls

/srv/pillar/
├── top.sls
└── common.sls

You can find the complete Salt configuration examples in this article ready to use in production!


This guide was originally written and tested against Salt 3006 on AlmaLinux 10.1. It was updated in July 2026: Salt now installs from the Salt Project repositories (it is no longer available from EPEL on EL10, and 3008 is the current LTS branch); the Docker repo is added in a way that works under dnf5; the firewall states use a zone and a policy object instead of deprecated --direct rules; and the wg-easy state targets v15 using its INIT_* unattended-setup variables instead of the v14 PASSWORD_HASH model, which no longer exists.

Related Articles:

Have questions about Salt or WireGuard automation? Drop a comment below!

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy