Running a Terraria Dedicated Server on NixOS

Posted

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.

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
	];
}

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.

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.

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.