diff --git a/configuration/default.nix b/configuration/default.nix
index d6cc5cf..76a1bf7 100644
--- a/configuration/default.nix
+++ b/configuration/default.nix
@@ -6,6 +6,7 @@
     ./services/minecraft.nix
     ./services/nextcloud.nix
     ./services/webserver.nix
+    ./services/starbound.nix
     ./ids.nix
   ];
 
@@ -20,6 +21,9 @@
     trustedUsers = [ "@wheel" ];
   };
 
+  nixpkgs.config.allowUnfreePredicate = pkg:
+    builtins.elem (lib.getName pkg) ["forge-server" "steam-runtime" "steamcmd"];
+
   sops = {
     defaultSopsFile = ../keys/external.yaml;
     secrets.steam = { };
@@ -34,7 +38,7 @@
     useDHCP = false;
     interfaces.eth0.useDHCP = true;
 
-    firewall.allowedTCPPorts = [ 80 443 2222 2221 25565 ];
+    firewall.allowedTCPPorts = [ 80 443 2222 2221 25565 21025 ];
   };
 
   time.timeZone = "Europe/London";
diff --git a/configuration/services/configs/starbound.json b/configuration/services/configs/starbound.json
new file mode 100644
index 0000000..d995fdf
--- /dev/null
+++ b/configuration/services/configs/starbound.json
@@ -0,0 +1,50 @@
+{
+  "allowAdminCommands" : true,
+  "allowAdminCommandsFromAnyone" : false,
+  "allowAnonymousConnections" : true,
+  "allowAssetsMismatch" : true,
+  "anonymousConnectionsAreAdmin" : false,
+  "bannedIPs" : [],
+  "bannedUuids" : [],
+  "checkAssetsDigest" : false,
+  "clearPlayerFiles" : false,
+  "clearUniverseFiles" : false,
+  "clientIPJoinable" : false,
+  "clientP2PJoinable" : true,
+  "configurationVersion" : {
+    "basic" : 2,
+    "server" : 4
+  },
+  "crafting" : {
+    "filterHaveMaterials" : false
+  },
+  "gameServerBind" : "::",
+  "gameServerPort" : 21025,
+  "interactiveHighlight" : true,
+  "inventory" : {
+    "pickupToActionBar" : true
+  },
+  "maxPlayers" : 8,
+  "maxTeamSize" : 4,
+  "monochromeLighting" : false,
+  "playerBackupFileCount" : 3,
+  "queryServerBind" : "::",
+  "queryServerPort" : 21025,
+  "rconServerBind" : "::",
+  "rconServerPassword" : "",
+  "rconServerPort" : 21026,
+  "rconServerTimeout" : 1000,
+  "runQueryServer" : false,
+  "runRconServer" : false,
+  "safeScripts" : true,
+  "scriptInstructionLimit" : 10000000,
+  "scriptInstructionMeasureInterval" : 10000,
+  "scriptProfilingEnabled" : false,
+  "scriptRecursionLimit" : 100,
+  "serverFidelity" : "automatic",
+  "serverName" : "tlater.net",
+  "serverOverrideAssetsDigest" : null,
+  "serverUsers" : {
+  },
+  "tutorialMessages" : true
+}
diff --git a/configuration/services/minecraft.nix b/configuration/services/minecraft.nix
index 23705ac..80b4ff5 100644
--- a/configuration/services/minecraft.nix
+++ b/configuration/services/minecraft.nix
@@ -51,9 +51,6 @@ let
   eula = pkgs.writeText "eula.txt" "eula=true";
 
 in {
-  nixpkgs.config.allowUnfreePredicate = pkg:
-    builtins.elem (lib.getName pkg) [ "forge-server" ];
-
   users = {
     extraUsers.minecraft = {
       uid = config.ids.uids.minecraft;
diff --git a/configuration/services/starbound.nix b/configuration/services/starbound.nix
new file mode 100644
index 0000000..e23028f
--- /dev/null
+++ b/configuration/services/starbound.nix
@@ -0,0 +1,110 @@
+{
+  pkgs,
+  lib,
+  ...
+}: let
+  inherit (lib) concatStringsSep;
+in {
+  systemd.services.starbound = {
+    description = "Starbound";
+    after = ["network.target"];
+
+    serviceConfig = {
+      ExecStart = "${pkgs.local.starbound}/bin/launch-starbound ${./configs/starbound.json}";
+
+      Type = "simple";
+
+      # Credential loading for steam auth (if necessary; prefer
+      # anonymous login wherever possible).
+      LoadCredential = "steam:/run/secrets/steam";
+
+      # Security settings
+      DynamicUser = true;
+
+      # This is where the StateDirectory ends up
+      WorkingDirectory = "/var/lib/starbound";
+      # Creates /var/lib/starbound (or rather, a symlink there to
+      # /var/lib/private/starbound), and sets it up to be writeable to
+      # by the dynamic user.
+      StateDirectory = "starbound";
+
+      # Note some settings below are basically tautologous with
+      # `NoNewPrivileges`, but they all work slightly differently so
+      # add additional layers in case of bugs.
+
+      ## THESE SETTINGS ARE A GOOD IDEA BUT THE STEAM CLIENT IS
+      ## REALLY, REALLY BAD, AND FOR SOME REASON I NEED TO USE IT TO
+      ## DOWNLOAD GAME SERVERS AS WELL:
+      ##
+      # To guarantee the above (only permits 64-bit syscalls, 32-bit
+      # syscalls can circumvent the above restrictions).
+      #
+      # Obviously, if running a 32 bit game server, change this.
+      # SystemCallArchitectures = "native";
+      # Game servers shouldn't need to create new namespaces ever.
+      #
+      # TODO: Since steam uses namespaces for things *entirely
+      # unrelated* to installing game servers, we need to allow
+      # namespace access. Ideally I'd instead do this in an
+      # ExecStartPre, but alas, this isn't possible because of
+      # https://github.com/systemd/systemd/issues/19604.
+      #
+      # RestrictNamespaces = true;
+
+      # Don't need to let the game server see other user accounts
+      PrivateUsers = true;
+      # *Probably* not harmful for game servers, which probably don't update dynamically
+      ProtectHostname = true;
+      # Yeah, if a game server tries to edit the hardware clock something's fishy
+      ProtectClock = true;
+      # Don't let game servers modify kernel settings, duh
+      ProtectKernelTunables = true;
+      ProtectKernelModules = true;
+      ProtectKernelLogs = true;
+      # Game servers shouldn't use cgroups themselves either
+      ProtectControlGroups = true;
+      # Most game servers will never need other socket types
+      RestrictAddressFamilies = ["AF_UNIX AF_INET AF_INET6"];
+      # Also a no-brainer, no game server should ever need this
+      LockPersonality = true;
+      # Some game servers will probably try to set this, but they
+      # don't need it. It's only required for audio processing and
+      # such, which the server end doesn't need to do.
+      RestrictRealtime = true;
+      # Don't allow a variety of syscalls that gameservers have no
+      # business using anyway
+      SystemCallFilter =
+        "~"
+        + (concatStringsSep " " [
+          "@clock"
+          "@cpu-emulation"
+          "@debug"
+          "@keyring"
+          "@memlock"
+          "@module"
+          # "@mount"  TODO: Consider adding when steamcmd is run in ExecStartPre
+          "@obsolete"
+          "@raw-io"
+          "@reboot"
+          "@resources"
+          "@setuid"
+          "@swap"
+        ]);
+      # Normally only "read-only", but steamcmd will puke if there is
+      # no home directory to write to (though the nix package will
+      # implicitly symlink to the path that we set in its override, so
+      # no actual files are created, besides a symlink).
+      ProtectHome = "tmpfs";
+
+      # Implied by DynamicUser anyway, but it doesn't hurt to add
+      # these explicitly, at least for reference.
+      RemoveIPC = true;
+      PrivateTmp = true;
+      PrivateDevices = true;
+      NoNewPrivileges = true;
+      RestrictSUIDSGID = true;
+      ProtectSystem = "strict";
+      # ProtectHome = "read-only"; # See further up
+    };
+  };
+}
diff --git a/flake.nix b/flake.nix
index d8928df..772580b 100644
--- a/flake.nix
+++ b/flake.nix
@@ -130,9 +130,26 @@
                     ])
                 ]))
             ];
-            shellHook = ''
+            shellHook = let
+              inherit (pkgs.lib.attrsets) mapAttrsToList;
+              inherit (pkgs.lib.strings) concatStringsSep;
+              ports = {
+                "3022" = "2222";
+                "3080" = "80";
+                "3443" = "443";
+                "3021" = "2221";
+                "25565" = "25565";
+                "21025" = "21025"; # Starbound
+              };
+              QEMU_NET_OPTS =
+                concatStringsSep ","
+                  (mapAttrsToList
+                    (host: vm: "hostfwd=::${host}-:${vm}")
+                    ports);
+            in
+              ''
               export QEMU_OPTS="-m 3941 -smp 2 -curses"
-              export QEMU_NET_OPTS="hostfwd=::3022-:2222,hostfwd=::3080-:80,hostfwd=::3443-:443,hostfwd=::3021-:2221,hostfwd=::25565-:25565"
+              export QEMU_NET_OPTS="${QEMU_NET_OPTS}"
 
               # Work around sudo requiring a full terminal
               export NIX_SSHOPTS="-t"
diff --git a/pkgs/default.nix b/pkgs/default.nix
index 88a0112..dd334bc 100644
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -13,4 +13,7 @@ in {
   # Minecraft modpacks
   voor-kia = callPackage ./minecraft/voor-kia.nix { };
   voor-kia-client = callPackage ./minecraft/voor-kia-client.nix { };
+
+  # Starbound
+  starbound = callPackage ./starbound { };
 }
diff --git a/pkgs/starbound/default.nix b/pkgs/starbound/default.nix
new file mode 100644
index 0000000..304f0f3
--- /dev/null
+++ b/pkgs/starbound/default.nix
@@ -0,0 +1,34 @@
+{
+  stdenv,
+  lib,
+  makeWrapper,
+  patchelf,
+  steamPackages,
+  replace-secret,
+}: let
+  # Use the directory in which starbound is installed so steamcmd
+  # doesn't have to be reinstalled constantly (we're using DynamicUser
+  # with StateDirectory to persist this).
+  steamcmd = steamPackages.steamcmd.override {
+    steamRoot = "/var/lib/starbound/.steamcmd";
+  };
+  wrapperPath = lib.makeBinPath [patchelf steamcmd replace-secret];
+in
+  stdenv.mkDerivation {
+    name = "starbound-update-script";
+    nativeBuildInputs = [makeWrapper];
+    dontUnpack = true;
+    patchPhase = ''
+      interpreter="$(cat $NIX_CC/nix-support/dynamic-linker)"
+      substitute ${./launch-starbound.sh} launch-starbound --subst-var interpreter
+    '';
+    installPhase = ''
+      mkdir -p $out/bin
+      cp launch-starbound $out/bin/launch-starbound
+      chmod +x $out/bin/launch-starbound
+    '';
+    postFixup = ''
+      wrapProgram $out/bin/launch-starbound \
+          --prefix PATH : "${wrapperPath}"
+    '';
+  }
diff --git a/pkgs/starbound/launch-starbound.sh b/pkgs/starbound/launch-starbound.sh
new file mode 100644
index 0000000..24d4db1
--- /dev/null
+++ b/pkgs/starbound/launch-starbound.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+set -eu
+
+if ! [[ -v STATE_DIRECTORY  &&  -v CREDENTIALS_DIRECTORY ]]; then
+    echo "Error: Runtime dir or credential not set"
+    exit 1
+fi
+
+# Update the server to the latest version
+echo "Updating/installing starbound"
+
+mkdir -p "${STATE_DIRECTORY}/.steamcmd"
+steamcmd <<EOF
+force_install_dir $STATE_DIRECTORY
+login tlater $(cat "$CREDENTIALS_DIRECTORY/steam")
+app_update 211820
+quit
+EOF
+
+echo "Updating config"
+if [ -f "$1" ]; then
+    mkdir -p ./storage
+    cp "$1" ./storage/starbound_server.config
+fi
+
+echo "Running starbound server"
+patchelf --set-interpreter '@interpreter@' ./linux/starbound_server
+# Must be run from the directory that the binary is in (why do game
+# devs do this?)
+cd linux
+./starbound_server