Dedicated Server für Heroes of Valor via Docker
Docker Compose zum Hosten eines Dedicated Servers für Heroes of Valor: SteamCMD-Image, Init-Container für die Permissions, Auto-Update bei jedem Start.
Einführung
Für Heroes of Valor gibt es kein fertiges Server-Image, das sich einfach starten lässt. Was es gibt: einen Dedicated Server auf Steam, das offizielle SteamCMD-Image — und die Notwendigkeit, beides mit ein paar Zeilen Shell zusammenzukleben. Das Endprodukt kann in einer compose.yml gebündelt werden, läuft als unprivilegierter User und aktualisiert sich bei jedem Neustart selbst.
Heroes of Valor ist ein Third-Person-Arcade-Shooter im Zweiten Weltkrieg, der sich offen an Battlefield 1942 und Battlefield Heroes anlehnt: Infanterie plus Panzer, Flugzeuge und Flak, dazu Klassen mit leicht überzeichneten Fähigkeiten. Dedicated Server waren lange angekündigt — jetzt sind sie da, und man kann sie auf eigener Hardware betreiben.
Die Compose-Datei
services:
hov-permissions: # einmaliger Init-Container: passende Permissions für /server, bevor hov-server als unprivilegierter User startet
image: busybox
container_name: hov-permissions
user: "0:0"
restart: "no"
volumes:
- ./server:/server
command: ["sh", "-c", "chown -R 1000:1000 /server"]
hov-server:
image: steamcmd/steamcmd:latest
container_name: hov-server
restart: unless-stopped
user: "1000:1000" # unprivilegierter User (kein Root)
depends_on:
hov-permissions:
condition: service_completed_successfully # wartet auf Abschluss (nicht nur Start)
environment:
- HOME=/server # SteamCMD & Engine legen ihre Daten relativ dazu ab
- SERVERNAME=ONLY_NOOBS
ports:
- "7777:7777/udp"
- "7778:7778/udp"
- "27015:27015/udp"
- "27015:27015/tcp"
volumes:
- ./server:/server
logging:
driver: "local" # rotiert automatisch, verhindert unbegrenzt wachsende Logs
entrypoint: [] # überschreibt den Image-eigenen ENTRYPOINT (steamcmd)
command:
- /bin/bash
- -c
- |
set -e
export PATH="$$PATH:/usr/games:/usr/local/games"
# SteamCMD-Binary automatisch finden, statt einen festen Pfad zu raten
if command -v steamcmd >/dev/null 2>&1; then
STEAMCMD="steamcmd"
elif [ -x /home/steam/steamcmd/steamcmd.sh ]; then
STEAMCMD="/home/steam/steamcmd/steamcmd.sh"
elif [ -x /usr/games/steamcmd ]; then
STEAMCMD="/usr/games/steamcmd"
else
echo "FEHLER: SteamCMD-Binary nicht gefunden. Gefundene Kandidaten:"
find / -maxdepth 4 -iname "steamcmd*" 2>/dev/null
exit 1
fi
echo "Verwende SteamCMD: $$STEAMCMD"
# bis zu 3 Versuche, falls Steam nicht erreichbar ist
for i in 1 2 3; do
"$$STEAMCMD" \
+@sSteamCmdForcePlatformType linux \
+login anonymous \
+app_update 4633280 validate \
+quit && break
echo "SteamCMD Versuch $$i fehlgeschlagen, erneuter Versuch in 5s..."
sleep 5
done
# Steam-Runtime-Lib an den von manchen Spielen erwarteten Pfad legen
mkdir -p /server/.steam/sdk64
SRC=/server/.local/share/Steam/steamcmd/linux64/steamclient.so
if [ -f "$$SRC" ] && [ ! -f /server/.steam/sdk64/steamclient.so ]; then
cp "$$SRC" /server/.steam/sdk64/steamclient.so
fi
# Engine-Logverzeichnis schreibgeschützt halten, um Log-Spam/Disk-Bloat zu verhindern
GAMELOGS="/server/.steam/steam/steamapps/common/HoV Dedicated Server/HeroesOfValor/Saved/Logs"
mkdir -p "$$GAMELOGS"
chmod 555 "$$GAMELOGS"
# exec ersetzt die Shell, damit Docker Signale (bspw. SIGTERM) korrekt durchreicht
exec /bin/bash "/server/.steam/steam/steamapps/common/HoV Dedicated Server/HeroesOfValorServer.sh" -ServerName="$$SERVERNAME"
services:
hov-permissions: # einmaliger Init-Container: passende Permissions für /server, bevor hov-server als unprivilegierter User startet
image: busybox
container_name: hov-permissions
user: "0:0"
restart: "no"
volumes:
- ./server:/server
command: ["sh", "-c", "chown -R 1000:1000 /server"]
hov-server:
image: steamcmd/steamcmd:latest
container_name: hov-server
restart: unless-stopped
user: "1000:1000" # unprivilegierter User (kein Root)
depends_on:
hov-permissions:
condition: service_completed_successfully # wartet auf Abschluss (nicht nur Start)
environment:
- HOME=/server # SteamCMD & Engine legen ihre Daten relativ dazu ab
- SERVERNAME=ONLY_NOOBS
ports:
- "7777:7777/udp"
- "7778:7778/udp"
- "27015:27015/udp"
- "27015:27015/tcp"
volumes:
- ./server:/server
logging:
driver: "local" # rotiert automatisch, verhindert unbegrenzt wachsende Logs
entrypoint: [] # überschreibt den Image-eigenen ENTRYPOINT (steamcmd)
command:
- /bin/bash
- -c
- |
set -e
export PATH="$$PATH:/usr/games:/usr/local/games"
# SteamCMD-Binary automatisch finden, statt einen festen Pfad zu raten
if command -v steamcmd >/dev/null 2>&1; then
STEAMCMD="steamcmd"
elif [ -x /home/steam/steamcmd/steamcmd.sh ]; then
STEAMCMD="/home/steam/steamcmd/steamcmd.sh"
elif [ -x /usr/games/steamcmd ]; then
STEAMCMD="/usr/games/steamcmd"
else
echo "FEHLER: SteamCMD-Binary nicht gefunden. Gefundene Kandidaten:"
find / -maxdepth 4 -iname "steamcmd*" 2>/dev/null
exit 1
fi
echo "Verwende SteamCMD: $$STEAMCMD"
# bis zu 3 Versuche, falls Steam nicht erreichbar ist
for i in 1 2 3; do
"$$STEAMCMD" \
+@sSteamCmdForcePlatformType linux \
+login anonymous \
+app_update 4633280 validate \
+quit && break
echo "SteamCMD Versuch $$i fehlgeschlagen, erneuter Versuch in 5s..."
sleep 5
done
# Steam-Runtime-Lib an den von manchen Spielen erwarteten Pfad legen
mkdir -p /server/.steam/sdk64
SRC=/server/.local/share/Steam/steamcmd/linux64/steamclient.so
if [ -f "$$SRC" ] && [ ! -f /server/.steam/sdk64/steamclient.so ]; then
cp "$$SRC" /server/.steam/sdk64/steamclient.so
fi
# Engine-Logverzeichnis schreibgeschützt halten, um Log-Spam/Disk-Bloat zu verhindern
GAMELOGS="/server/.steam/steam/steamapps/common/HoV Dedicated Server/HeroesOfValor/Saved/Logs"
mkdir -p "$$GAMELOGS"
chmod 555 "$$GAMELOGS"
# exec ersetzt die Shell, damit Docker Signale (bspw. SIGTERM) korrekt durchreicht
exec /bin/bash "/server/.steam/steam/steamapps/common/HoV Dedicated Server/HeroesOfValorServer.sh" -ServerName="$$SERVERNAME"
Starten:
mkdir -p server
docker compose up -d
docker compose logs -f hov-server
Der erste Start dauert, weil SteamCMD die Serverdateien erst herunterlädt. Danach ist der Server unter deiner öffentlichen IP auf Port 7777 erreichbar.
Warum der Init-Container?
Der eigentliche Server läuft bewusst nicht als root, sondern als UID 1000. Ein Bind-Mount wie ./server übernimmt aber die Besitzverhältnisse vom Host — und ein frisch angelegtes Verzeichnis gehört dort in der Regel root. SteamCMD dürfte dann nichts hineinschreiben und bricht mit Permission-Fehlern ab.
Genau dafür ist hov-permissions da: ein Wegwerf-Container aus BusyBox, der als root startet, das Verzeichnis auf UID 1000 umschreibt und sich sofort wieder beendet.
Wichtig ist die Bedingung im depends_on:
depends_on:
hov-permissions:
condition: service_completed_successfully
Ohne die Bedingung würde Compose nur warten, bis der Init-Container gestartet ist — nicht, bis er fertig ist. Bei einem chown -R über tausende Dateien ist das ein Rennen, das man verliert.
[!NOTE] Der
chown-Lauf passiert bei jedemuperneut. Bei einer installierten Serverinstanz mit vielen Dateien kostet das ein paar Sekunden. Wen das stört, der baut eine Bedingung ein, die nur bei falschem Besitzer umschreibt.
Die Stolperfallen im Startskript
$$ statt $
Das ist die Falle, die am meisten Zeit frisst. Compose interpretiert ${...} und $VAR selbst, bevor der Container überhaupt startet. Ein $PATH im Skript wäre also schon von Compose ersetzt — durch nichts. Ein doppeltes $$ wird zu einem literalen $, das dann bei der Shell im Container ankommt. Deshalb steht im Skript überall $$PATH, $$STEAMCMD und $$SERVERNAME.
entrypoint: []
Das SteamCMD-Image startet standardmäßig direkt SteamCMD. Wir brauchen aber eine Shell, die erst aktualisiert und dann den Spielserver startet. Die leere Liste löscht den ENTRYPOINT des Images, sodass command als vollständiger Startbefehl gilt.
HOME=/server
SteamCMD und die Unreal-Engine legen ihre Daten relativ zum Home-Verzeichnis ab: ~/.steam, ~/.local. Zeigt HOME auf /server, landet alles im gemounteten Verzeichnis — Installation, Konfiguration und Savegames überleben also jedes Neuerstellen des Containers.
Das Binary suchen statt raten
Wo steamcmd im Image liegt, hat sich über die Versionen mehrfach geändert. Das Skript probiert deshalb der Reihe nach PATH, /home/steam/steamcmd/steamcmd.sh und /usr/games/steamcmd durch — und gibt im Fehlerfall die gefundenen Kandidaten aus, statt kommentarlos zu sterben.
steamclient.so an den erwarteten Ort kopieren
Viele Unreal-Server erwarten die Steam-Runtime-Bibliothek unter ~/.steam/sdk64/steamclient.so. SteamCMD legt sie aber woanders ab. Ohne diese Kopie startet der Server zwar, taucht aber in keiner Serverliste auf — ein klassisches Symptom, das man sonst stundenlang bei den Ports sucht.
Das Logverzeichnis dichtmachen
GAMELOGS=".../HeroesOfValor/Saved/Logs"
mkdir -p "$$GAMELOGS"
chmod 555 "$$GAMELOGS"
Der Server schreibt sehr gesprächige Logdateien. Ein Verzeichnis ohne Schreibrecht ist die brachiale, aber wirksame Methode dagegen. Die Konsolenausgabe bleibt erhalten und wird über Dockers local-Treiber ohnehin rotiert.
[!WARNING] Nach einem
validate-Lauf oder einer Neuinstallation kann das Verzeichnis neu angelegt werden. Das Skript setzt die Rechte deshalb bei jedem Start erneut.
exec am Ende
Ohne exec bliebe die Shell als PID 1 stehen und der Spielserver wäre nur ihr Kindprozess. Ein docker compose stop schickt SIGTERM dann an die Shell, nicht an den Server — der Prozess würde nach Ablauf der Frist hart abgeschossen. exec ersetzt die Shell durch den Serverprozess, damit Signale dort ankommen, wo sie hingehören.
[!TIP] Unreal-Server brauchen zum sauberen Beenden gelegentlich länger als die zehn Sekunden, die Docker standardmäßig zugesteht. Ein
stop_grace_period: 30sbeim Service schadet nicht.
Ports
| Port | Protokoll | Zweck |
|---|---|---|
| 7777 | UDP | Spielverkehr |
| 7778 | UDP | Query/Beacon der Engine |
| 27015 | UDP + TCP | Steam-Serverabfrage |
Alle drei müssen in der Firewall offen und — hinter NAT — auf den Host weitergeleitet sein. Der Spielverkehr läuft ausschließlich über UDP; wer nur TCP freigibt, wundert sich über einen Server, der pingbar ist, aber niemanden hereinlässt.
Betrieb
Updates
Es gibt keinen separaten Update-Schritt. Weil app_update bei jedem Containerstart läuft, genügt:
docker compose restart hov-server
Nach einem Spiel-Patch ist der Server damit auf dem aktuellen Stand. Wer den Neustart automatisieren will, hängt einen Cron-Eintrag zu einer nachtruhigen Uhrzeit daran.
Backup
Alles Relevante liegt unter ./server. Für ein schlankes Backup reicht das Save-Verzeichnis:
tar czf hov-$(date +%F).tar.gz "server/.steam/steam/steamapps/common/HoV Dedicated Server/HeroesOfValor/Saved"
Die Serverdateien selbst lädt SteamCMD jederzeit neu herunter — die muss man nicht sichern.
Logs
docker compose logs -f --tail=100 hov-server
Troubleshooting
Der Server taucht in keiner Liste auf. Erst die steamclient.so-Kopie prüfen, dann die UDP-Ports. In dieser Reihenfolge, nicht umgekehrt.
Permission denied beim Download. Der Init-Container ist nicht gelaufen oder das Volume gehört jemand anderem: ls -ln server zeigt, ob dort tatsächlich UID 1000 steht.
SteamCMD bricht mit Netzwerkfehler ab. Die Retry-Schleife fängt kurze Aussetzer ab. Hält es länger an, ist meist Steam selbst gerade nicht erreichbar.
Container startet in einer Schleife neu. docker compose logs ansehen — wenn set -e bei einem fehlgeschlagenen Befehl zuschlägt, steht die Ursache in den letzten Zeilen davor.