#!/bin/bash # panel-native Valheim entrypoint. set -euo pipefail log() { printf '[panel-valheim] %s\n' "$*"; } # Root-stage: chown (SteamCMD sidecar wrote as root) then drop to panel. if [[ "$(id -u)" = "0" ]]; then chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true exec setpriv --reuid=panel --regid=panel --clear-groups \ --inh-caps=-all --bounding-set=-all \ env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ SERVER_NAME="${SERVER_NAME:-panel Valheim}" \ SERVER_WORLD="${SERVER_WORLD:-Dedicated}" \ SERVER_PASSWORD="${SERVER_PASSWORD:-secret12}" \ SERVER_PUBLIC="${SERVER_PUBLIC:-1}" \ /entrypoint.sh "$@" fi GAME_DIR=/game SAVE_DIR=/game-saves EXE="${GAME_DIR}/valheim_server.x86_64" CFG="${SAVE_DIR}/server.cfg" if [[ ! -x "${EXE}" ]]; then log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Valheim via SteamCMD." exit 78 fi # --- settings source of truth: PANEL ENV (config_values) --- # The panel is the single editing surface (gamehost Settings page writes # config_values → these env vars on recreate). The env values ALWAYS win. # # We REGENERATE /game-saves/server.cfg from the live env on every boot, purely # as a human-readable reflection of the active settings — it is NOT sourced # back in. The previous version seeded it once and then SOURCED it on every # boot, so a stale server.cfg silently overrode every panel edit (a customer's # renamed server / new password never took effect, and the Settings page showed # values that disagreed with the running server). Fixed 2026-06-02. cat > "${CFG}" < 0. if [[ "${SERVER_SAVEINTERVAL:-}" =~ ^[0-9]+$ ]] && [[ "${SERVER_SAVEINTERVAL}" -gt 0 ]]; then EXTRA_ARGS+=(-saveinterval "${SERVER_SAVEINTERVAL}") fi # World preset: one of the vanilla presets (normal/casual/easy/hard/hardcore/ # immersive/hammer). Empty = leave at the world's existing settings. if [[ -n "${SERVER_PRESET:-}" ]]; then EXTRA_ARGS+=(-preset "${SERVER_PRESET}") fi # Per-key world modifiers: SERVER_MODIFIERS holds space-separated "key value" # pairs, e.g. "combat hard deathpenalty casual resources more". Each pair # becomes a "-modifier key value". Operator-facing fields write a clean string. if [[ -n "${SERVER_MODIFIERS:-}" ]]; then # shellcheck disable=SC2206 _mods=(${SERVER_MODIFIERS}) i=0 while [[ $i -lt ${#_mods[@]} ]]; do key="${_mods[$i]}"; val="${_mods[$((i+1))]:-}" if [[ -n "${key}" && -n "${val}" ]]; then EXTRA_ARGS+=(-modifier "${key}" "${val}") fi i=$((i+2)) done fi # --- Mods: BepInEx (Doorstop) loader --------------------------------------- # Valheim mods load through BepInEx, injected at launch by Unity Doorstop. The # panel's mod manager installs BepInEx + plugins into /game/BepInEx (the # Thunderstore/ValheimPlus zips extract their BepInEx/ tree + doorstop_libs + # libdoorstop into /game). We enable injection ONLY when: # - mods are turned on (VALHEIM_MODS_ENABLED=1, set by the panel), AND # - the loader is actually present on disk (BepInEx/core/BepInEx.Preloader.dll) # Otherwise we launch vanilla (no perf cost, no risk of a half-install bricking # the server). cwd is already /game below so the relative paths resolve. BEPINEX_PRELOADER="${GAME_DIR}/BepInEx/core/BepInEx.Preloader.dll" MODS_ACTIVE=0 if [[ "${VALHEIM_MODS_ENABLED:-0}" != "0" && -f "${BEPINEX_PRELOADER}" ]]; then MODS_ACTIVE=1 fi # ValheimPlus config (valheim_plus.cfg) is NOT generated here. gamehost writes # the full cfg directly into /game/BepInEx/config/valheim_plus.cfg via the file # API whenever the customer saves V+ settings (it holds all 345 settings in # config_values as VP_* and renders the cfg from them). Keeping cfg generation # out of the container env avoids declaring 345 env vars per instance. cd "${GAME_DIR}" log "starting Valheim dedicated server" log " name: ${SERVER_NAME}" log " world: ${SERVER_WORLD}" log " public: ${SERVER_PUBLIC}" log " port: ${GAME_PORT} (+ ${GAME_PORT}+1 for query)" log " extra: ${EXTRA_ARGS[*]:-(none)}" if [[ "${MODS_ACTIVE}" = "1" ]]; then log " mods: BepInEx ENABLED${VALHEIM_PLUS_ENABLED:+ (ValheimPlus)}" # Doorstop env (from ValheimPlus/BepInEx start_server_bepinex.sh). Paths are # relative to cwd (=/game). export DOORSTOP_ENABLED=1 export DOORSTOP_ENABLE=TRUE export DOORSTOP_TARGET_ASSEMBLY="./BepInEx/core/BepInEx.Preloader.dll" export DOORSTOP_INVOKE_DLL_PATH="./BepInEx/core/BepInEx.Preloader.dll" export LD_LIBRARY_PATH="./doorstop_libs:${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}" export LD_PRELOAD="libdoorstop_x64.so:${LD_PRELOAD:-}" else log " mods: none (vanilla)" fi # --- Passwordless via ValheimPlus ----------------------------------------- # disableServerPassword=true in valheim_plus.cfg is NOT enough on its own: # Valheim still enforces the password if the launch command passes -password, # and it refuses a passwordless server unless it's also -public 0 (unlisted). # So when V+ is active AND its cfg has disableServerPassword=true, we OMIT # -password and force -public 0. (Confirmed: Iron Gate / ServerFlex / multiple # V+ docker projects all hit exactly this.) PW_ARGS=(-password "${SERVER_PASSWORD}") PUBLIC_ARG="${SERVER_PUBLIC}" VP_CFG="${GAME_DIR}/BepInEx/config/valheim_plus.cfg" if [[ "${MODS_ACTIVE}" = "1" && "${VALHEIM_PLUS_ENABLED:-0}" != "0" && -f "${VP_CFG}" ]]; then # Match `disableServerPassword=true` only inside the [Server] section. if awk ' /^\[Server\]/ {inserver=1; next} /^\[/ {inserver=0} inserver && /^[[:space:]]*disableServerPassword[[:space:]]*=[[:space:]]*true[[:space:]]*$/ {found=1} END {exit found?0:1} ' "${VP_CFG}"; then log " password: DISABLED via ValheimPlus (omitting -password, forcing -public 0)" PW_ARGS=() PUBLIC_ARG="0" fi fi exec stdbuf -oL -eL "${EXE}" \ -name "${SERVER_NAME}" \ -port "${GAME_PORT}" \ -world "${SERVER_WORLD}" \ "${PW_ARGS[@]}" \ -public "${PUBLIC_ARG}" \ -savedir "${SAVE_DIR}" \ "${EXTRA_ARGS[@]}" \ -nographics -batchmode