Skip to content

Instantly share code, notes, and snippets.

@ntanis-dev
Last active February 9, 2026 06:37
Show Gist options
  • Select an option

  • Save ntanis-dev/2ff7433e8e7db5f41886f77171c66d0b to your computer and use it in GitHub Desktop.

Select an option

Save ntanis-dev/2ff7433e8e7db5f41886f77171c66d0b to your computer and use it in GitHub Desktop.
Project Zomboid dedicated-server watchdog: ensures the server is always running inside a GNU screen session, periodically checks for Steam Workshop mod updates via the server console, and applies them by restarting only when no players are online, otherwise it warns connected players in-game and defers the restart until the server is empty.
#!/usr/bin/env bash
set -euo pipefail
# Update the following parameters to reflect your server's setup.
START_CMD="/home/pzserver/start-server.sh"
STEAMCMD="/home/steamcmd/steamcmd.sh"
LOG_DIR="/root/Zomboid/Logs"
LOG_GLOB="*DebugLog-server.txt"
# Those parameters should be okay as they are.
SCREEN_SERVER="project-zomboid-server"
PZ_PGREP_PATTERN="ProjectZomboid"
WATCHDOG_TICK="15s"
CHECK_EVERY="3m"
CHECK_TIMEOUT="60s"
LOCKFILE="/tmp/project-zomboid-watchdog.lock"
SCREEN_WATCHDOG="project-zomboid-watchdog"
SELF="$(readlink -f "$0")"
is_pm2() { [[ -n "${PM2_HOME:-}" || -n "${pm_id:-}" ]]; }
screen_exists() {
screen -list 2>/dev/null | grep -qE "[[:space:]][0-9]+\.${1}([[:space:]]|$)"
}
case "${1:-}" in
start)
if is_pm2; then
echo "Running under pm2 — use 'pm2 start' instead."
exit 1
fi
if screen_exists "$SCREEN_WATCHDOG" 2>/dev/null; then
echo "Watchdog is already running (screen: $SCREEN_WATCHDOG)."
exit 0
fi
screen -dmS "$SCREEN_WATCHDOG" bash -lc "exec \"$SELF\" run"
echo "Watchdog started in screen '$SCREEN_WATCHDOG'."
exit 0
;;
stop)
if screen_exists "$SCREEN_WATCHDOG" 2>/dev/null; then
screen -S "$SCREEN_WATCHDOG" -X quit
echo "Watchdog stopped."
else
echo "Watchdog is not running."
fi
exit 0
;;
status)
if screen_exists "$SCREEN_WATCHDOG" 2>/dev/null; then
echo "Watchdog is running (screen: $SCREEN_WATCHDOG)."
else
echo "Watchdog is not running."
fi
exit 0
;;
run|"")
;;
*)
echo "Usage: $0 {start|stop|status}"
exit 1
;;
esac
ts() { date +"%F %T"; }
log() { echo "[$(ts)] $*"; }
start_server_screen() {
log "Starting server in screen '$SCREEN_SERVER' -> $START_CMD"
screen -dmS "$SCREEN_SERVER" bash -lc "exec \"$START_CMD\""
}
wait_for_stop() {
while pgrep -f "$PZ_PGREP_PATTERN" >/dev/null 2>&1; do sleep 1; done
}
latest_log() {
ls -1t "$LOG_DIR"/$LOG_GLOB 2>/dev/null | head -n1 || true
}
screen_send() {
screen -S "$SCREEN_SERVER" -X stuff $"$1\n"
}
get_player_count() {
local logf
logf="$(latest_log)"
[[ -z "${logf:-}" ]] && echo "-1" && return
screen_send "players"
local count=""
count="$(
timeout 10s stdbuf -oL -eL tail -n0 -F "$logf" \
| awk -F'[()]' '/Players connected/ {print $2; exit}' \
|| true
)"
echo "${count:--1}"
}
ensure_server_running() {
local has_screen=0 has_proc=0
screen_exists "$SCREEN_SERVER" && has_screen=1 || true
pgrep -f "$PZ_PGREP_PATTERN" >/dev/null 2>&1 && has_proc=1 || true
if [[ $has_screen -eq 0 && $has_proc -eq 0 ]]; then
start_server_screen
sleep 2
return
fi
if [[ $has_screen -eq 1 && $has_proc -eq 0 ]]; then
log "Screen exists but server process is dead -> restarting"
screen -S "$SCREEN_SERVER" -X quit || true
start_server_screen
sleep 2
return
fi
if [[ $has_screen -eq 0 && $has_proc -eq 1 ]]; then
log "Server process running but screen missing -> restarting into screen"
pkill -f "$PZ_PGREP_PATTERN" || true
wait_for_stop
start_server_screen
sleep 2
return
fi
}
check_mods_need_update() {
ensure_server_running
local logf
logf="$(latest_log)"
if [[ -z "${logf:-}" ]]; then
log "No log found at $LOG_DIR/$LOG_GLOB (will retry)"
return 2
fi
log "Checking mods (log: $logf)"
screen_send "checkModsNeedUpdate"
local result=""
result="$(
timeout "$CHECK_TIMEOUT" stdbuf -oL -eL tail -n0 -F "$logf" \
| awk '/need update/ {print "NEEDUPDATE"; exit} /updated/ {print "UPTODATE"; exit}' \
|| true
)"
[[ "$result" == "NEEDUPDATE" ]] && return 0
[[ "$result" == "UPTODATE" ]] && return 1
log "No clear result within $CHECK_TIMEOUT"
return 2
}
restart_and_update() {
log "Mods need update -> restarting (no players connected)"
log "Stopping server..."
screen_send "quit"
wait_for_stop
screen -S "$SCREEN_SERVER" -X quit || true
log "Server stopped."
start_server_screen
log "Server restarted."
}
exec 9>"$LOCKFILE"
flock -n 9 || { log "Another Watchdog instance is already running."; exit 0; }
log "Watchdog started. watchdog tick=$WATCHDOG_TICK, mod check=$CHECK_EVERY"
parse_interval_to_seconds() {
local s="$1"
case "$s" in
*s) echo "${s%s}";;
*m) echo $(( ${s%m} * 60 ));;
*h) echo $(( ${s%h} * 3600 ));;
*) echo "$s";;
esac
}
check_interval_sec="$(parse_interval_to_seconds "$CHECK_EVERY")"
tick_sec="$(parse_interval_to_seconds "$WATCHDOG_TICK")"
next_check_epoch=$(( $(date +%s) + check_interval_sec ))
update_pending=0
update_notified=0
while true; do
ensure_server_running
now=$(date +%s)
if [[ $update_pending -eq 1 ]]; then
player_count="$(get_player_count)"
if [[ "$player_count" -eq 0 ]]; then
restart_and_update
update_pending=0
update_notified=0
next_check_epoch=$(( $(date +%s) + check_interval_sec ))
else
log "Waiting for $player_count player(s) to disconnect..."
fi
elif (( now >= next_check_epoch )); then
if check_mods_need_update; then
player_count="$(get_player_count)"
if [[ "$player_count" -eq 0 ]]; then
restart_and_update
else
log "Mods need update but $player_count player(s) connected — deferring restart"
screen_send 'servermsg "Mod updates pending, the server will restart once all players disconnect."'
update_pending=1
update_notified=1
fi
fi
next_check_epoch=$(( now + check_interval_sec ))
fi
sleep "$tick_sec"
done
@ntanis-dev
Copy link
Author

ntanis-dev commented Feb 9, 2026

The best way to use this is with pm2, otherwise just start it and it will self-contain in a screen. The watchdog will start your server for you and monitor it as described.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment