Skip to content

Unraid PIA WireGuard wg0 Auto-Refresh Setup

Purpose

This setup automatically refreshes Unraid built-in WireGuard tunnel wg0 when the PIA VPN tunnel loses internet connectivity.

The PIA config generator runs inside a Docker container using wg1, because the ISP blocks the PIA generator script when run from the normal ISP connection.

Final behavior

wg0 has internet
  do nothing

wg0 has no internet
  watchdog increases failure counter

wg0 fails 3 consecutive checks
  watchdog creates:
  /boot/config/wireguard/pia-refresh.request

Docker container using wg1 detects request
  runs:
  python3 pia_wg_gen.py

Docker writes generated config:
  /boot/config/wireguard/wg0.generated

Next watchdog run
  reads wg0.generated
  extracts only:
    Interface PrivateKey
    Interface Address
    Peer PublicKey
    Peer Endpoint

  patches:
    /boot/config/wireguard/wg0.conf

  preserves:
    PostUp
    PostDown
    AllowedIPs
    PersistentKeepalive

  restarts wg0
  deletes pending files
````

## Important files

```text
/boot/config/wireguard/wg0.conf

Main Unraid WireGuard tunnel config.

/boot/config/wireguard/wg0.generated

Temporary generated PIA config waiting to be applied.

/boot/config/wireguard/pia-refresh.request

Trigger file. When this exists, the Docker worker generates a new PIA config.

/boot/config/wireguard/pia-watchdog.state

Failure counter file.

/boot/config/wireguard/unraid_wg0_watchdog.sh

Main Unraid host watchdog script.

/mnt/user/appdata/pia-wg-refresh/

Docker appdata folder for the PIA refresh worker.


1. Folder structure

Create the appdata folder:

mkdir -p /mnt/user/appdata/pia-wg-refresh

Place the PIA generator script here:

/mnt/user/appdata/pia-wg-refresh/pia_wg_gen.py

Confirm:

ls -l /mnt/user/appdata/pia-wg-refresh/pia_wg_gen.py

2. Docker worker script

Create:

nano /mnt/user/appdata/pia-wg-refresh/pia_refresh_worker.py

Paste:

#!/usr/bin/env python3
import configparser
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path

BASE_DIR = Path(os.getenv("BASE_DIR", "/app"))
REQUEST_FILE = Path(os.getenv("REQUEST_FILE", "/wireguard/pia-refresh.request"))
PENDING_FILE = Path(os.getenv("PENDING_FILE", "/wireguard/wg0.generated"))
OUTPUT_CONF = Path(os.getenv("OUTPUT_CONF", str(BASE_DIR / "wg0.conf")))
GEN_CMD = os.getenv("GEN_CMD", "python3 pia_wg_gen.py")
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "30"))

REQUIRED_INTERFACE_KEYS = ("PrivateKey", "Address")
REQUIRED_PEER_KEYS = ("PublicKey", "Endpoint")


def load_wg_config(path: Path) -> configparser.ConfigParser:
    parser = configparser.ConfigParser(strict=False, interpolation=None)
    parser.optionxform = str
    with path.open("r", encoding="utf-8") as handle:
        parser.read_file(handle)
    return parser


def validate_generated_config(path: Path) -> None:
    parser = load_wg_config(path)

    if "Interface" not in parser or "Peer" not in parser:
        raise ValueError("Generated config must contain [Interface] and [Peer].")

    for key in REQUIRED_INTERFACE_KEYS:
        value = parser["Interface"].get(key, "").strip()
        if not value:
            raise ValueError(f"Generated config missing [Interface] {key}.")

    for key in REQUIRED_PEER_KEYS:
        value = parser["Peer"].get(key, "").strip()
        if not value:
            raise ValueError(f"Generated config missing [Peer] {key}.")

    endpoint = parser["Peer"].get("Endpoint", "").strip()
    if ":" not in endpoint:
        raise ValueError("Generated [Peer] Endpoint must include host:port.")


def run_generator() -> None:
    if OUTPUT_CONF.exists():
        OUTPUT_CONF.unlink()

    result = subprocess.run(
        GEN_CMD,
        cwd=str(BASE_DIR),
        shell=True,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        timeout=180,
    )

    if result.returncode != 0:
        raise RuntimeError(
            f"Generator failed with exit code {result.returncode}.\n"
            f"STDOUT:\n{result.stdout}\n"
            f"STDERR:\n{result.stderr}"
        )

    if not OUTPUT_CONF.exists():
        stdout_text = result.stdout.strip()
        if "[Interface]" in stdout_text and "[Peer]" in stdout_text:
            OUTPUT_CONF.write_text(stdout_text + "\n", encoding="utf-8")
        else:
            raise FileNotFoundError(
                f"Generator did not create {OUTPUT_CONF} and stdout did not contain wg config."
            )

    validate_generated_config(OUTPUT_CONF)

    temp_pending = PENDING_FILE.with_suffix(".generated.tmp")
    shutil.copyfile(OUTPUT_CONF, temp_pending)
    os.replace(temp_pending, PENDING_FILE)
    REQUEST_FILE.unlink(missing_ok=True)

    print(f"Created pending config: {PENDING_FILE}", flush=True)


def main() -> int:
    print("PIA refresh worker started.", flush=True)

    while True:
        try:
            if REQUEST_FILE.exists() and not PENDING_FILE.exists():
                print("Refresh request detected.", flush=True)
                run_generator()
        except Exception as exc:
            print(f"ERROR: {exc}", file=sys.stderr, flush=True)

        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    raise SystemExit(main())

Validate syntax:

python3 -m py_compile /mnt/user/appdata/pia-wg-refresh/pia_refresh_worker.py

3. Dockerfile

Create:

nano /mnt/user/appdata/pia-wg-refresh/Dockerfile

Paste:

FROM python:3.12-alpine

WORKDIR /app

ENV BASE_DIR=/app \
    REQUEST_FILE=/wireguard/pia-refresh.request \
    PENDING_FILE=/wireguard/wg0.generated \
    OUTPUT_CONF=/app/wg0.conf \
    GEN_CMD="python3 pia_wg_gen.py" \
    POLL_SECONDS=30

RUN apk add --no-cache \
    curl \
    ca-certificates \
    wireguard-tools \
    iproute2 \
    bash \
    && python3 -m pip install --no-cache-dir --upgrade pip \
    && python3 -m pip install --no-cache-dir cryptography

COPY pia_refresh_worker.py /app/pia_refresh_worker.py
COPY pia_wg_gen.py /app/pia_wg_gen.py

RUN chmod 700 /app/pia_refresh_worker.py /app/pia_wg_gen.py

CMD ["python3", "/app/pia_refresh_worker.py"]

Build image:

docker build --no-cache -t pia-wg-refresh:latest /mnt/user/appdata/pia-wg-refresh

4. Docker container

Create the container from terminal:

docker run -d \
  --name pia-wg-refresh \
  --restart unless-stopped \
  --network wg1 \
  -v /boot/config/wireguard:/wireguard:rw \
  -v /mnt/user/appdata/pia-wg-refresh:/app:rw \
  pia-wg-refresh:latest

Check logs:

docker logs -f pia-wg-refresh

Expected:

PIA refresh worker started.

Check dependencies:

docker exec -it pia-wg-refresh sh

Inside container:

python3 -c "import cryptography; print('cryptography OK')"
curl --version
exit

Expected:

cryptography OK

5. Unraid watchdog script

Create:

nano /boot/config/wireguard/unraid_wg0_watchdog.sh

Paste:

#!/bin/bash
set -u

WG_NAME="wg0"
WG_FILE="/boot/config/wireguard/wg0.conf"
WIREGUARD_DIR="/boot/config/wireguard"
GENERATED_FILE="$WIREGUARD_DIR/wg0.generated"
REQUEST_FILE="$WIREGUARD_DIR/pia-refresh.request"
STATE_FILE="$WIREGUARD_DIR/pia-watchdog.state"
LOCK_DIR="/tmp/pia-wg0-watchdog.lock"
FAIL_LIMIT=3
PING_TARGET="1.1.1.1"
GENERATED_WAIT_SECONDS=120
GENERATED_WAIT_INTERVAL=5

log() {
  logger -t pia-wg0-watchdog -- "$*"
  echo "$*"
}

cleanup() {
  rmdir "$LOCK_DIR" 2>/dev/null || true
}

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  log "Another watchdog run is active. Exiting."
  exit 0
fi

trap cleanup EXIT

read_generated_value() {
  local section="$1"
  local key="$2"

  awk -v section="$section" -v key="$key" '
    BEGIN { in_section=0 }
    /^[[:space:]]*\[/ {
      in_section = ($0 ~ "^[[:space:]]*\\[" section "\\]")
    }
    in_section == 1 {
      line=$0
      sub(/#.*/, "", line)
      split(line, parts, "=")
      left=parts[1]
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", left)

      if (left == key) {
        value=substr(line, index(line, "=") + 1)
        gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
        print value
        exit
      }
    }
  ' "$GENERATED_FILE"
}

patch_wg0_config() {
  local private_key address public_key endpoint tmp_file backup_file ts

  private_key="$(read_generated_value Interface PrivateKey)"
  address="$(read_generated_value Interface Address)"
  public_key="$(read_generated_value Peer PublicKey)"
  endpoint="$(read_generated_value Peer Endpoint)"

  if [ -z "$private_key" ] || [ -z "$address" ] || [ -z "$public_key" ] || [ -z "$endpoint" ]; then
    log "Generated config missing one or more required values. Refusing to patch."
    return 1
  fi

  if [[ "$endpoint" != *:* ]]; then
    log "Generated Endpoint is invalid: $endpoint"
    return 1
  fi

  ts="$(date +%Y%m%d-%H%M%S)"
  backup_file="$WIREGUARD_DIR/wg0.conf.backup.$ts"
  tmp_file="$WIREGUARD_DIR/wg0.conf.tmp.$ts"

  cp -p "$WG_FILE" "$backup_file"

  awk \
    -v private_key="$private_key" \
    -v address="$address" \
    -v public_key="$public_key" \
    -v endpoint="$endpoint" '
    BEGIN { section="" }

    /^[[:space:]]*\[Interface\][[:space:]]*$/ {
      section="Interface"
      print
      next
    }

    /^[[:space:]]*\[Peer\][[:space:]]*$/ {
      section="Peer"
      print
      next
    }

    /^[[:space:]]*\[/ {
      section="Other"
      print
      next
    }

    section == "Interface" && $0 ~ /^[[:space:]]*PrivateKey[[:space:]]*=/ {
      print "PrivateKey=" private_key
      next
    }

    section == "Interface" && $0 ~ /^[[:space:]]*Address[[:space:]]*=/ {
      print "Address=" address
      next
    }

    section == "Peer" && $0 ~ /^[[:space:]]*PublicKey[[:space:]]*=/ {
      print "PublicKey=" public_key
      next
    }

    section == "Peer" && $0 ~ /^[[:space:]]*Endpoint[[:space:]]*=/ {
      print "Endpoint=" endpoint
      next
    }

    { print }
  ' "$WG_FILE" > "$tmp_file"

  if ! grep -q '^PrivateKey=' "$tmp_file" || \
     ! grep -q '^Address=' "$tmp_file" || \
     ! grep -q '^PublicKey=' "$tmp_file" || \
     ! grep -q '^Endpoint=' "$tmp_file"; then
    log "Patched config validation failed. Keeping existing config."
    rm -f "$tmp_file"
    return 1
  fi

  mv "$tmp_file" "$WG_FILE"
  chmod 600 "$WG_FILE"

  log "Patched $WG_FILE. Backup: $backup_file"
}

restart_wg0() {
  if [ -x /etc/rc.d/rc.wireguard ]; then
    /etc/rc.d/rc.wireguard stop "$WG_NAME" || true
    sleep 2
    /etc/rc.d/rc.wireguard start "$WG_NAME" && return 0
  fi

  if command -v wg-quick >/dev/null 2>&1; then
    wg-quick down "$WG_NAME" || true
    sleep 2
    wg-quick up "$WG_FILE" && return 0
  fi

  log "No supported WireGuard restart command found."
  return 1
}

wg0_has_internet() {
  ping -I "$WG_NAME" -c 2 -W 3 "$PING_TARGET" >/dev/null 2>&1
}

apply_generated_if_present() {
  if [ ! -f "$GENERATED_FILE" ]; then
    return 1
  fi

  log "Generated config found. Applying to $WG_FILE."

  if patch_wg0_config && restart_wg0; then
    rm -f "$GENERATED_FILE" "$REQUEST_FILE" "$STATE_FILE"
    log "wg0 refresh completed."
    return 0
  fi

  log "wg0 refresh failed. Generated file retained: $GENERATED_FILE"
  return 1
}

request_refresh_and_wait() {
  local waited

  if [ ! -f "$REQUEST_FILE" ]; then
    date -Iseconds > "$REQUEST_FILE"
    log "Created refresh request: $REQUEST_FILE"
  else
    log "Refresh request already exists: $REQUEST_FILE"
  fi

  waited=0

  while [ "$waited" -lt "$GENERATED_WAIT_SECONDS" ]; do
    if [ -f "$GENERATED_FILE" ]; then
      log "Generated config detected after ${waited}s. Applying now."
      apply_generated_if_present
      return $?
    fi

    sleep "$GENERATED_WAIT_INTERVAL"
    waited=$((waited + GENERATED_WAIT_INTERVAL))
  done

  log "Timed out waiting for generated config after ${GENERATED_WAIT_SECONDS}s. It will be applied on next run if created."
  return 1
}

apply_generated_if_present && exit 0

fail_count=0

if [ -f "$STATE_FILE" ]; then
  fail_count="$(cat "$STATE_FILE" 2>/dev/null || echo 0)"
fi

if ! [[ "$fail_count" =~ ^[0-9]+$ ]]; then
  fail_count=0
fi

if wg0_has_internet; then
  echo 0 > "$STATE_FILE"
  log "wg0 health OK."
  exit 0
fi

fail_count=$((fail_count + 1))
echo "$fail_count" > "$STATE_FILE"

log "wg0 health failed. Consecutive failures: $fail_count/$FAIL_LIMIT"

if [ "$fail_count" -ge "$FAIL_LIMIT" ]; then
  request_refresh_and_wait
fi

Make executable:

chmod 700 /boot/config/wireguard/unraid_wg0_watchdog.sh

Validate syntax:

bash -n /boot/config/wireguard/unraid_wg0_watchdog.sh

6. Manual health test

Run:

ping -I wg0 -c 3 -W 3 1.1.1.1
echo $?

Expected:

0

Run curl through wg0:

curl --interface wg0 -4 --connect-timeout 10 https://ifconfig.me
echo $?

Expected:

0

Run watchdog manually:

/boot/config/wireguard/unraid_wg0_watchdog.sh

Expected if wg0 is healthy:

wg0 health OK.

7. Manual full refresh test

Clean old files:

rm -f /boot/config/wireguard/pia-refresh.request
rm -f /boot/config/wireguard/wg0.generated
rm -f /boot/config/wireguard/pia-watchdog.state

Create manual request:

date -Iseconds > /boot/config/wireguard/pia-refresh.request

Check Docker logs:

docker logs -f pia-wg-refresh

Expected:

Refresh request detected.
Created pending config: /wireguard/wg0.generated

Stop logs:

CTRL+C

Confirm pending config exists:

ls -l /boot/config/wireguard/wg0.generated

Apply generated config:

/boot/config/wireguard/unraid_wg0_watchdog.sh

Expected:

Generated config found. Applying to /boot/config/wireguard/wg0.conf.
Patched /boot/config/wireguard/wg0.conf. Backup: /boot/config/wireguard/wg0.conf.backup.YYYYMMDD-HHMMSS
wg0 refresh completed.

Confirm cleanup:

ls -l /boot/config/wireguard/pia-refresh.request /boot/config/wireguard/wg0.generated 2>/dev/null

Expected:

No output

Confirm wg0 works:

ping -I wg0 -c 3 -W 3 1.1.1.1
wg show wg0

8. Unraid User Scripts schedule

Only schedule this script:

/boot/config/wireguard/unraid_wg0_watchdog.sh

Do not schedule:

python3 pia_wg_gen.py

Do not schedule the Docker worker. The Docker worker must only stay running.

In Unraid:

  1. Go to Settings.
  2. Open User Scripts.
  3. Click Add New Script.
  4. Name:
wg0 PIA Watchdog
  1. Edit script.
  2. Paste:
#!/bin/bash
/boot/config/wireguard/unraid_wg0_watchdog.sh
  1. Save.
  2. Click Run Script manually first.

Expected:

wg0 health OK.
  1. Set schedule to custom cron:
* * * * *

This runs every 1 minute.


9. Timing behavior

With:

FAIL_LIMIT=3

And schedule:

* * * * *

Expected recovery flow:

Minute 1:
  wg0 check failed
  Consecutive failures: 1/3

Minute 2:
  wg0 check failed
  Consecutive failures: 2/3

Minute 3:
  wg0 check failed
  Consecutive failures: 3/3
  creates pia-refresh.request

Docker worker:
  generates wg0.generated

Minute 4:
  watchdog sees wg0.generated
  patches wg0.conf
  restarts wg0
  deletes request/generated files

Approximate recovery time:

3 to 4 minutes

This is intentional to avoid unnecessary refresh from one temporary ping failure.


10. Normal status check

Run:

cat /boot/config/wireguard/pia-watchdog.state 2>/dev/null
ls -l /boot/config/wireguard/pia-refresh.request /boot/config/wireguard/wg0.generated 2>/dev/null

Normal healthy result:

0

No pending files should show.


11. Logs

Check watchdog logs:

grep pia-wg0-watchdog /var/log/syslog

Check Docker worker logs:

docker logs --tail 50 pia-wg-refresh

Live Docker logs:

docker logs -f pia-wg-refresh

12. Rollback

List backups:

ls -lh /boot/config/wireguard/wg0.conf.backup.*

Restore one backup:

cp -p /boot/config/wireguard/wg0.conf.backup.YYYYMMDD-HHMMSS /boot/config/wireguard/wg0.conf
chmod 600 /boot/config/wireguard/wg0.conf

Restart wg0:

/etc/rc.d/rc.wireguard stop wg0
sleep 2
/etc/rc.d/rc.wireguard start wg0

If that does not work:

wg-quick down wg0
sleep 2
wg-quick up /boot/config/wireguard/wg0.conf

13. Rebuild container after script changes

If pia_wg_gen.py, pia_refresh_worker.py, or Dockerfile changes:

docker stop pia-wg-refresh 2>/dev/null
docker rm pia-wg-refresh 2>/dev/null

docker build --no-cache -t pia-wg-refresh:latest /mnt/user/appdata/pia-wg-refresh

docker run -d \
  --name pia-wg-refresh \
  --restart unless-stopped \
  --network wg1 \
  -v /boot/config/wireguard:/wireguard:rw \
  -v /mnt/user/appdata/pia-wg-refresh:/app:rw \
  pia-wg-refresh:latest

14. Safety notes

The Docker container has write access to:

/boot/config/wireguard

But it does not directly restart wg0.

Only the Unraid host watchdog script patches and restarts wg0.

This is safer than giving the container:

/var/run/docker.sock

or full host privileges.

The automation only replaces these values in wg0.conf:

[Interface]
PrivateKey
Address

[Peer]
PublicKey
Endpoint

It does not replace:

PostUp
PostDown
AllowedIPs
PersistentKeepalive
DNS