DEV Community

Kevin Cox
Kevin Cox

Posted on • Originally published at kevincox.ca on

Running a Terraria Dedicated Server on NixOS

I recently started playing Terraria and have to say it is a very fun game! To let anyone play at any time I decided to spin up a dedicated server. Unfortunately the Terraria server isn’t what I would consider great server software. Here is how I got a good setup working on NixOS.

The main flaw with the Terraria server is that it doesn’t gracefully handle SIGTERM. Generally it is expected that upon receiving SIGTERM an application will gracefully shut down. But Terraria just exits immediately without saving the world! With the non-configurable 30min save interval this means that it is easy to lose a lot of data with a naive setup. Even more annoying is that the way to trigger a graceful exit is to run the server in the console and type save or exit (which saves). But I don’t want to do this manually every time I update Terraria, change the config or restart my server! So we need to automate this.

Note: NixOS does have a Terraria module, but I wasn’t a fan with how it worked. I’ll discuss the improvements as I get to them. Maybe we can pull some of these improvements upstream.

Config

Let’s just start with a big config dump, then I’ll go over the interesting bits later.

{config, lib, pkgs, ...}: let
    dataDir = "/var/lib/terraria";
    worldDir = "${dataDir}/worlds";

    # Simple config file serializer. Not very robust.
    mkConfig = options:
        builtins.toFile
            "terraria.cfg"
            (lib.concatStrings
                (lib.mapAttrsToList
                    (name: value: "${name}=${toString value}\n")
                    options));

    # Config Generator
    mkWorld = name: {
        worldSize ? "large",
    }: {
        config = mkConfig {
            world = "${worldDir}/${name}.wld";
            password = "YOUR PASSWORD HERE!!!";
            seed = "kevincox-${name}";
            autocreate = { small = 1; medium = 2; large = 3; }.${worldSize};
            upnp = 0;
        };
    };

    # High-level Config
    worlds = lib.mapAttrs mkWorld {
        my-first-world = {};
        some-other-world = {
            worldSize = "medium";
        };
    };

    world = worlds.my-first-world;
in {
    users.users.terraria = {
        group = "terraria";
        home = dataDir;
        uid = config.ids.uids.terraria; # NixOS has a Terraria user, so use those IDs.
    };

    users.groups.terraria = {
        gid = config.ids.gids.terraria;
    };

    systemd.sockets.terraria = {
        socketConfig = {
            ListenFIFO = ["/run/terraria.sock"];
            SocketUser = "terraria";
            SocketMode = "0660";
            RemoveOnStop = true;
        };
    };

    systemd.services.terraria = {
        wantedBy = [ "multi-user.target" ];
        after = [ "network.target" ];
        bindsTo = ["terraria.socket"];

        preStop = ''
 printf '\nexit\n' >/run/terraria.sock
 '';

        serviceConfig = {
            User = "terraria";
            ExecStart = lib.escapeShellArgs [
                "${pkgs.terraria-server}/bin/TerrariaServer"
                "-config" world.config
            ];

            StateDirectory = "terraria";
            StateDirectoryMode = "0750";

            StandardInput = "socket";
            StandardOutput = "journal";
            StandardError = "journal";

            KillSignal = "SIGCONT"; # Wait for exit after `ExecStop` (https://github.com/systemd/systemd/issues/13284)
            TimeoutStopSec = "1h";
        };
    };

    kevincox.backup.terraria.paths = [
        dataDir
    ];
}

Warning: I currently just hardcode the password in the config file. This means that it is visible both in my Git repo and world-readable in the Nix store. I don’t consider this password very sensitive so I consider that fine. If desired the config file could be moved to a secret deployed via something like nixops. But for now I am fine with this.

Graceful Exits

As mentioned earlier Terraria has very ungraceful exits by default. Most of the complexity in this configuration is just handling that. The basic strategy is:

  1. Create a socket for stdin.
  2. Set up ExecStop (via preStop) to request a graceful exit.

The idea is simple but the implementation is tedious. It took a lot of doc-reading and trial-and-error to get everything working. I’m not going to go over the details but just try deleting any of the related lines to watch it fail.

  1. Create a terraria.socket unit for the stdin pipe.
  2. Bind the units together. Otherwise stopping the socket leaves you unable to gracefully kill the server.
  3. Configure StandardInput which requires configuring StandardOutput and StandardError as the defaults would otherwise change.
  4. Configure preStop to request an exit.
  5. Because our preStop is async we need to disable the KillSignal. We do this by using a noop signal.

As a side benefit we can just echo whatever we want to the socket to run other admin commands. It is a bit awkward because the output will show up in the journal rather than your console but this seems like the best tradeoff. Just run journalctl -f terraria in another window to see the output.

Note: The NixOS module handles this by setting up tmux. While this does give a better interactive experience it increased complexity and swallowed logs. Also the socket is put in the state directory (/var/lib) rather than the runtime directory (/run) which made my backup tool angry. Both the logs and socket location were fixable, but I opted for the system with less moving parts in the end.

World Management

I wanted to be able to declaratively provision new worlds. This is what the worlds variable is for. I can define a bunch of different worlds and then switch between them just by updating the world pointer. The first time a world is loaded it will be automatically generated according to the settings. Most settings (like seed and world size) then become inert. So it is not perfectly declarative, but that is largely expected for persistent data and accomplishes my main use cases of automatically provisioning a new world with just a config change.

For now the only option I support is worldSize, but I will surely add more knobs as I need them. For example if I start up another world I might want a per-world password.

Warning: The NixOS module seems to have trouble with this. When tyring to start up with services.terraria.worldPath set to a location that didn’t exist it would create a new world in /var/lib/terraria/.local/share/Terraria/Worlds every time the server started. I don’t know why this is as I didn’t have any issues when creating my module, maybe the config file option is handled better than the command-line flag?

Config File

I chose to generate a config file rather than passing command-line flags. The main reason for this is that the config file appears to contain a superset of the options available on the command line. So I figured if I start using those options it will be easier to just keep everything in the config file rather than generating both flags and config.

For now the config file generator uses no escaping. I don’t even know if Terraria supports any escaping. Ideally I would at least assert that the substituted value doesn’t contain a newline, but I didn’t feel like it.

Backups

Another nice feature of a dedicated server is that I can take and publish world backups. Any player can grab a copy if they want and data-loss in the case of a catastrophic event is limited. Of course, you can also take backups on the host’s computer. But it is nice to just roll into the regular process and monitoring that I already have on my servers.

Top comments (0)