Half-Life Dedicated Server With Docker
Tags: docker, linux, gamedev
Published: 2024-06-24 13:03:31


I used to play a lot of [Half-Life](https://store.steampowered.com/app/70/HalfLife/) back in the day. I remember spending hours on all the mods available, such as [Counter-Strike](https://store.steampowered.com/app/10/CounterStrike/) and [Day of Defeat](https://store.steampowered.com/app/30/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](https://developer.valvesoftware.com/wiki/Half-Life_Dedicated_Server) and [Docker](https://www.docker.com/).

![Containers in Counter-Strike](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8v4b7j7b1nqdfylejw01.png)

If you're unfamiliar with Docker, it's a tool that lets you run applications in isolated environments, [known as containers](https://www.docker.com/resources/what-container/). 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](https://steampowered.com) on the host machine and installing dependencies.
2. Installing [SteamCMD](https://developer.valvesoftware.com/wiki/SteamCMD), a terminal utility provided by [Valve](https://valvesoftware.com) for installing content from Steam.
3. Downloading the game files and starting the server.

> You can find further documentation and explanations for all the concepts in this article on either the <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://developer.valvesoftware.com/wiki/">Valve Developer Wiki</a> or the <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://docs.docker.com">Docker documentation site</a>.

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](https://store.steampowered.com/app/70/HalfLife/), [Counter-Strike](https://store.steampowered.com/app/10/CounterStrike/), [Team Fortress Classic](https://store.steampowered.com/app/20/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](https://ubuntu.com/), 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](https://developer.valvesoftware.com/wiki/SteamCMD) used to install content distributed through [Steam Pipe](https://developer.valvesoftware.com/wiki/SteamPipe). 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.

```bash
@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.

> The value of `$GAME` can either be `valve` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/70/HalfLife/">Half-Life Deathmatch</a>, `cstrike` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/10/CounterStrike/">Counter-Strike</a>, `czero` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/80/CounterStrike_Condition_Zero/">Counter-Strike Condition Zero</a>, `dmc` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/40/Deathmatch_Classic/">Deatchmatch Classic</a>, `gearbox` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/50/HalfLife_Opposing_Force/">Half-Life Opposing Force</a>, `ricochet` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/60/Ricochet/">Ricochet</a>, `dod` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/30/Day_of_Defeat/">Day of Deafeat</a>, or `tfc` for <a className="!text-white" rel="noopener noreferrer" target="_blank" href="https://store.steampowered.com/app/20/Team_Fortress_Classic/">Team Fortress Classic</a>.


### 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`. 

```bash
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.

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

```bash
$ export GAME=cstrike
$ docker compose build
```

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

```bash
$ 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.


![CS Server on the list](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bdqxywso7xjsahgklxtz.png)


![Steam Deck](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7t0s4k41u4mh86zuf59o.jpg)


## 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](https://docs.docker.com/storage/volumes/). 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.

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

```bash
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.

```bash
$ export GAME=decay
$ docker compose build
```

## Conclusion

Overall, this was a fun side, so hopefully, you found my explanation of it informative. This project forced me to learn the difference between [ARM](https://en.wikipedia.org/wiki/ARM_architecture_family) and [x86-64](https://en.wikipedia.org/wiki/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](https://github.com/JamesIves/hlds-docker), including some [pre-built images that remove a fair amount of boilerplate that this article covers that you can find on Dockerhub](https://hub.docker.com/repository/docker/jives/hlds).
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. 

```yml
services:
  hlds:
    build: docker
    # 📣 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](https://github.com/JamesIves/hlds-docker/discussions).