Last active
February 9, 2026 06:37
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.