Half-Life Dedicated Server With Docker
Overview
I used to play a lot of Half-Life back in the day. I remember spending hours on all the mods available, such as Counter-Strike and Day of Defeat, and I even ran a few servers for me and my friends to play together. I played with all the various custom maps and mods available back then and remember having a blast doing so. I recently decided to revisit the game and wondered what it would take to set up a server with modern techniques using the Half-Life Dedicated Server client and Docker.
If you're unfamiliar with Docker, it's a tool that lets you run applications in isolated environments, known as containers. These containers ensure consistency and portability by containing all the dependencies and configurations needed to run an application. As a result, you can run the container on any machine with Docker installed and be confident that the application will run consistently, regardless of who runs it or where it's run, so long as the system architecture has support. When it comes to building a dedicated server with Docker, it is a great option, as all it takes to set one up is a series of terminal commands and a basic knowledge of how Half-Life organizes its game directories.
The general process of setting up a dedicated server includes:
- Creating a Steam user on the host machine and installing dependencies.
- Installing SteamCMD, a terminal utility provided by Valve for installing content from Steam.
- Downloading the game files and starting the server.
For this project, I had a couple of high-level goals in mind that I wanted to achieve:
- The server should be able to support all the classic Valve games, such as Half-Life Deathmatch, Counter-Strike, Team Fortress Classic, etc.
- You should be able to install custom server plugins, configurations and maps.
- You should be able to play custom mods other than the officially supported ones.
Building the Dockerfile
To start building a container, you must first create a file named Dockerfile
, which contains scripts that will run as the container is building. These scripts help set up the structure of the host machine by creating necessary users, giving them permissions, and downloading all required files. You're essentially running a series of terminal commands in sequence, just like you would if you set this up on your system.
In the following example, we extend from a pre-built Ubuntu image, install some known dependencies, add our Steam user account on the system, and then switch to the working directory located at /opt/steam
.
FROM ubuntu:18.04
ARG GAME=valve
ENV GAME ${GAME}
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends curl rsync file libc6:i386 lib32stdc++6 ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -r steam && \
useradd -r -g steam -m -d /opt/steam steam
USER steam
WORKDIR /opt/steam
Next, we'll install and invoke SteamCMD, a command-line version of the Steam client used to install content distributed through Steam Pipe. We'll use this to install the dedicated server client for Half-Life.
COPY ./hlds.txt /opt/steam
RUN sed -i "s/\$GAME/${GAME}/g" /opt/steam/hlds.txt
RUN curl -v -sL media.steampowered.com/client/installer/steamcmd_linux.tar.gz | tar xzvf - && \
file /opt/steam/linux32/steamcmd && \
./steamcmd.sh +runscript /opt/steam/hlds.txt
Some things are worth mentioning, particularly the arguments passed to steamcmd.sh
. We call +runscript hlds.txt,
which points to a text file that batches a series of commands for the Steam client. Here, we log in to Steam anonymously, request the files for title ID 90 (the Steam title ID for the Half-Life Dedicated Server client), and then validate its contents. We do this multiple times as there's a known bug with SteamCMD where it will always fail on the first try for Half-Life mods.
Below, you'll find an example of what hlds.txt
looks like.
@ShutdownOnFailedCommand 0
@NoPromptForPassword 1
force_install_dir ./hlds
login anonymous
app_set_config 90 mod $GAME
app_update 90 validate
app_update 90 validate
app_update 90 validate
quit
In Half-Life, every game based on it is treated as a mod, even if it's an official Valve title. Most of them originally started as such. Therefore, to download the correct files for our server, we must provide the mod's name to SteamCMD so it pulls the proper files. Before we request anything from SteamCMD, we replace any occurrence of $GAME
in hlds.txt
. This environment variable is also used throughout the script to ensure that we're moving specific files into the proper directories.
$GAME
can either be valve
for Half-Life Deathmatch, cstrike
for Counter-Strike, czero
for Counter-Strike Condition Zero, dmc
for Deatchmatch Classic, gearbox
for Half-Life Opposing Force, ricochet
for Ricochet, dod
for Day of Deafeat, or tfc
for Team Fortress Classic.Starting the Server
At the end of Dockerfile
we define an entrypoint.
WORKDIR /opt/steam/hlds
COPY ./entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
The entry point script runs when we start the container. In this script, we prompt the dedicated server client to connect to Steam to allow players to join by calling ./hlds_run
.
GAME=${GAME:-valve}
./hlds_run "-game $GAME $@"
You'll notice we're passing -game $GAME
as an argument, as this tells the server client what mod directory to use. Half-Life splits all of its mods into subdirectories. These subdirectory names correspond with the same name passed to the +app_update
command we used earlier. While you can only install officially supported things via SteamCMD, these folders can either be an official mod or a community-made mod, and as such, the -game
argument needs to point to which mod folder within the leading Half-Life directory you want to run.
Additionally, we're passing $@
as an argument, the contents of this source from our docker-compose.yml
file. This file acts as a manifest and tells Docker how to build and run our container, including any required ports to enable and additional parameters to pass to the entry point when its called. In this case, we're passing server startup arguments such as the max players and the map to start on.
In almost all cases the server requires a valid +map
value to start properly, otherwise the server will start but not be joinable.
services:
hlds:
build:
context: .
args:
- GAME=${GAME}
ports:
- "27015:27015/udp"
- "27015:27015"
- "26900:2690/udp"
environment:
- GAME=${GAME}
command: +maxplayers 12 +log on +map cs_italy
With everything configured, you only need to run the following command to build the container image.
$ export GAME=cstrike
$ docker compose build
We can start our server with a single command if the image builds successfully.
$ docker compose up
With a bit of luck you should see the server start up and be joinable from the game client. If you're having trouble, always best to check the server logs that appear in the terminal output.
Supporting Custom Configs and Mods
Server configurations and plugins are typically installed by overwriting specific files within the mod directory. For example, to install a custom map rotation for Counter-Strike, you must add a mapcycle.txt
alongside the rest of the files, for instance, hlds\cstrike\mapcycle.txt
.
Custom mods are installed in a similar fashion and should be placed directly within our server's leading directory. For example, for a mod named "decay", the location would be hlds\decay
. Server configurations for that mod would be placed in hlds\decay\server.cfg
as an example.
To do this, we can utilize a Docker feature called volume mapping. This allows us to map a directory on the host machine to a directory inside the container when it starts. The benefit is that we can rapidly adjust our server without having to rebuild the image every time; all we need to do is stop the process and re-run docker compose up
. We need to adjust our docker-compose.yml
file for this to work. In this example, we're pushing the local ./config
and ./mods
directories within our folder to a directory called ./temp/config
and ./temp/mods
within the container.
services:
hlds:
build:
context: .
args:
- GAME=${GAME}
volumes:
- "./config:/temp/config"
- "./mods:/temp/mods"
ports:
- "27015:27015/udp"
- "27015:27015"
- "26900:2690/udp"
environment:
- GAME=${GAME}
command: +maxplayers 12 +log on +map dy_accident1
Within the entrypoint.sh
file, we copy the files from the temp directories into their respective places within the container; this will ensure that when the server starts, it has all the files it needs to run the respective mod or load any server configurations and plugins in the right place without altering the base image every time.
if [ -d /temp/mods ]
then
rsync --chown=steam:steam /temp/mods/* /opt/steam/hlds
fi
if [ -d /temp/config ]
then
rsync --chown=steam:steam /temp/config/* /opt/steam/hlds/$GAME
fi
echo -e "\e[32mStarting Half-Life Dedicated Server for $GAME...\e[0m"
./hlds_run "-game $GAME $@"
In all cases, you must ensure the $GAME
environment variable is pointing to the name of the mod folder you want to play before you start the container, official or not as this ensures the server gets the correct -game
parameter on startup.
$ export GAME=decay
$ docker compose build
Conclusion
Overall, this was a fun side project, so hopefully, you found my explanation of it informative. This project forced me to learn the difference between ARM and x86-64 architecture, as many dependencies don't work with ARM based systems. Before trying anything, be sure you're using a compatible system, or you'll learn the hard way, as I did.
All the associated code is available on GitHub, including some pre-built images that remove a fair amount of boilerplate that this article covers that you can find on Dockerhub.
If you want to skip all of the above to run a Counter-Strike server, you only need to use docker compose up
with the following docker-compose.yml
file.
services:
hlds:
# 📣 Adjust the image value here with the desired game you want the server to use.
image: jives/hlds:cstrike
# 📣 Add all your custom configs/mods to a mods or config directory placed alongside the compose file.
volumes:
- "./config:/temp/config"
- "./mods:/temp/mods"
ports:
- "27015:27015/udp"
- "27015:27015"
- "26900:2690/udp"
environment:
- GAME=${GAME}
# 📣 Modify your server startup commands here.
# 📣 Remember: Stating map is based on the game, and will likely be different between images.
command: +maxplayers 12 +map cs_italy
If you try the project and have any feedback or questions, please let me know by leaving a note on the GitHub Discussions board.
Related Reading
Check out the following articles from the archives.