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.

Chicken 🔪 🐔

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:

  1. Creating a Steam user on the host machine and installing dependencies.
  2. Installing SteamCMD, a terminal utility provided by Valve for installing content from Steam.
  3. Downloading the game files and starting the server.
Jives Freeman
You can find further documentation and explanations for all the concepts in this article on either the Valve Developer Wiki or the Docker documentation site.

For this project, I had a couple of high-level goals in mind that I wanted to achieve:

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.

Dockerfile
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.

Dockerfile
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.

hlds.txt
@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.

Jives Calhoun
The value of $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.

Dockerfile
WORKDIR /opt/steam/hlds

COPY --chown=steam:steam ./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.

entrypoint.sh
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.

docker-compose.yml
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.

terminal
$ export GAME=cstrike
$ docker compose build

We can start our server with a single command if the image builds successfully.

terminal
$ 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.

Half-Life Dedicated Server on the Steam server list

Go Go Go

Steam Deck showing the server message of the day

The Steam Deck was a big time saver as it allowed me to rapidly prototype

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.

docker-compose.yml
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.

entrypoint.sh
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.

terminal
$ 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.

docker-compose.yml
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.

Check out the following articles from the archives.