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:
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
- Always test first: Use
test=True before applying - Use version control: Store states in Git
- Modular states: Keep states small and focused
- Use pillars for sensitive data: Never hardcode passwords in states
- Document your states: Add comments explaining what and why
- Use requisites: Ensure proper ordering with
require, watch, onchanges - Idempotent states: States should be safe to run multiple times
- 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 command
✅ Maintain consistent configuration across multiple servers
✅ Version control your infrastructure
✅ Scale to hundreds of VPN servers effortlessly
✅ Implement automated monitoring and backups
✅ Quickly respond to security updates
✅ Reduce 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
- Set up GitLab/GitHub CI/CD for automated testing of Salt states
- Implement Salt monitoring with Prometheus and Grafana
- Create custom execution modules for complex WireGuard operations
- Set up Salt reactors for auto-healing
- Explore Salt Cloud for automated cloud provisioning
- 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!