diff --git a/home/apps/ssh.nix b/home/apps/ssh.nix
index 0d11e63fe51ed2eb8d3ee31884132bb3d667b8b4..4e849caf7d734b8c9bafbb3e4d00bf412d118e8c 100644
--- a/home/apps/ssh.nix
+++ b/home/apps/ssh.nix
@@ -73,6 +73,9 @@ Sets up SSH user config.
     # GitHub
     github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
 
+    # GitLab (https://docs.gitlab.com/ee/user/gitlab_com/index.html#ssh-known_hosts-entries)
+    gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf
+
     # FachschaftenGit
     gitlab.fachschaften.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMK5HFOCvLwU139LPzqW8E1WP8OXj7PcPkIUhqhCLF8l
 
diff --git a/home/desktop/base.nix b/home/desktop/base.nix
index e68e4d42ce020057f5a6ea080418b9e2d498ce5a..8d87b9fc36ef360727669b4019e8c0b4bdc13a34 100644
--- a/home/desktop/base.nix
+++ b/home/desktop/base.nix
@@ -73,7 +73,18 @@ lib.mkIf (!config.eisfunke.headless) {
         # autostart apps
         { command = "element-desktop"; }
         { command = "thunderbird"; }
-        { command = "vivaldi --class=vivaldi-startup"; }
+
+        /*
+        Vivaldi supports setting a window class / app_id via `--class`, so I could use an assign
+        and a special app_id to have a specific Vivaldi window opening in the desired workspace.
+
+        However, this app_id is reused for children windows created by e.g. ctrl+n, which means that
+        those would also appear in that workspace, even if the parent window is currently somewhere
+        else, which is annoying.
+
+        So instead, I use this cursed workaround to move the window after startup.
+        */
+        { command = "vivaldi && sleep 3 && swaymsg '[app_id=\"vivaldi-stable\"] move workspace 6'"; }
 
         /*
         I want this repo to be opened on a specific workspace. However, vscode doesn't have a way
diff --git a/home/desktop/workspaces.nix b/home/desktop/workspaces.nix
index 4eac4b7eb19f748d2ce60299cb186e769e46261e..b9b7201363c860700e1c5c742a0bf175f8e3e0db 100644
--- a/home/desktop/workspaces.nix
+++ b/home/desktop/workspaces.nix
@@ -41,7 +41,7 @@
           "0" = [ { app_id = "YouTube Music"; } ];
           "1" = [ { app_id = "Element"; } ];
           "2" = [ { app_id = "thunderbird"; } ];
-          "6" = [ { app_id = "vivaldi-startup"; } ];
+          # some autostarted apps are moved to a workspace by a script instead, see ./base.nix
         };
 
       };
diff --git a/nixos/extra.nix b/nixos/extra.nix
index d116ea47df4b2eb410841138e3780fa9e3b29d72..63a97c207cb14798b7286758a195c0e3287421dd 100644
--- a/nixos/extra.nix
+++ b/nixos/extra.nix
@@ -4,7 +4,15 @@ misc stuff that's not required for a basic system
 
 { pkgs, inputs', lib, config, ... }:
 
-lib.mkIf (!config.eisfunke.minimal) {
+let
+  runscRootless = pkgs.writeShellApplication {
+    name = "runsc-rootless";
+    runtimeInputs = [ pkgs.gvisor ];
+    text = ''
+      exec runsc -ignore-cgroups "$@"
+    '';
+  };
+in lib.mkIf (!config.eisfunke.minimal) {
   boot.binfmt.emulatedSystems = [
     "wasm32-wasi"
     "wasm64-wasi"
@@ -17,9 +25,32 @@ lib.mkIf (!config.eisfunke.minimal) {
       dns_enabled = true;
       network_interface = "br-podman";  # bridge device interface name
     };
+    /*
+    - runs `podman system prune -f` weekly, pruning dangling data
+    - dangling images: untagged images
+    - untagged images are created e.g. when pulling a new xyz:latest image
+    - the previous "latest" image is now untagged, if it's unused it's dangling and will be pruned
+    - still tagged unused images won't be removed automatically
+    - manually run `podman system prune --all` for that
+    */
     autoPrune.enable = true;
+    /*
+    - gVisor provides an alternative container runtime `runsc`
+    - provides better isolation
+    - TODO how
+    - I use this for GitLab Runner CI containers
+    - TODO grist
+    - can be used rootless
+      - `podman run -it --rm --runtime runsc --runtime-flag ignore-cgroups public.ecr.aws/docker/library/alpine:latest`
+      - see also https://github.com/google/gvisor/issues/311
+    */
+    extraPackages = [
+      pkgs.gvisor
+    ];
   };
 
+  environment.systemPackages = [ runscRootless ];
+
   # bluetooth connections can be managed statefully with `bluetoothctl`
   hardware.bluetooth = {
     enable = true;
diff --git a/nixos/kodi.nix b/nixos/kodi.nix
index 431fdd5e3c81b74568757b1d8b89654a99523102..5e81aba3408156d740743abeabd1aefbcb773242 100644
--- a/nixos/kodi.nix
+++ b/nixos/kodi.nix
@@ -10,9 +10,12 @@ let
   ]);
 in lib.mkIf (config.networking.hostName == "amethyst") {
   # create Kodi user
+  # TODO oops uid clash
   users.users.kodi = {
     isNormalUser = true;
     extraGroups = [ "video" ];  # for access to HDMI CEC device
+    uid = 1002;
+    autoSubUidGidRange = false;  # doesn't need them
   };
 
   services = {
diff --git a/nixos/server/age.nix b/nixos/server/age.nix
index e77ac12d7ca401bbe46800bed8ef358a55adbe28..f0e7215214230ce71114c7f55e588c55f1ffaae9 100644
--- a/nixos/server/age.nix
+++ b/nixos/server/age.nix
@@ -259,5 +259,10 @@ Agenix secrets only used in the server config are defined here instead of the to
       owner = "git";
       group = "git";
     };
+    server-gitlab-runner-podman = {
+      file = secretsPath + /server/gitlab-runner-podman.age;
+      owner = "root";
+      group = "root";
+    };
   };
 }
diff --git a/nixos/server/default.nix b/nixos/server/default.nix
index d2eba41a3c2dec7a9fb81ac2354d4d7564bf2008..4ad497ec982cf62afe5ba6ac068685be0652e23b 100644
--- a/nixos/server/default.nix
+++ b/nixos/server/default.nix
@@ -21,6 +21,7 @@ modules for my services, only used on sapphire, my homeserver
     ./gallery.nix
     ./gca4hpx.nix
     ./git.nix
+    ./gitlab-runner.nix
     ./issuebot.nix
     ./lists.nix
     ./literature.nix
diff --git a/nixos/server/gitlab-runner.nix b/nixos/server/gitlab-runner.nix
new file mode 100644
index 0000000000000000000000000000000000000000..2106e5917360a5ce6c01f4fe48df7340f1a1e996
--- /dev/null
+++ b/nixos/server/gitlab-runner.nix
@@ -0,0 +1,94 @@
+{ lib, config, ... }:
+
+{
+  # https://github.com/NixOS/nixpkgs/blob/c3aa7b8938b17aebd2deecf7be0636000d62a2b9/nixos/modules/services/continuous-integration/gitlab-runner.nix#L730
+  /*users = {
+    users.gitlab-runner = {
+      group = "gitlab-runner";
+      uid = config.ids.uids.gitlab-runner;  # TODO was used before dynamicuser
+      # TODO https://docs.gitlab.com/runner/executors/docker.html#use-podman-to-run-docker-commands
+      linger = true;
+      # set subuids/subgids for rootless podman use, hardcode ranges to ensure determinism
+      subUidRanges = [
+        { startUid = 100000; count = 65536; }
+      ];
+      subGidRanges = [
+        { startGid = 100000; count = 65536; }
+      ];
+    };
+    groups.gitlab-runner.gid = config.ids.gids.gitlab-runner;
+  };*/
+
+  systemd.services.gitlab-runner = {
+    # add the podman socket as prerequisite
+    after = [ "podman.socket" ];
+    requires = [ "podman.socket" ];
+    serviceConfig = {
+      /*
+      - turning off DynamicUser will run the runner service as root
+      - so it can use rootful podman for gVisor support
+      - while `runsc` *can* be used rootless with some workaraounds, I don't do that
+      - rootless gVisor isn't really documented
+      - doesn't seem "officially" supported
+      - can't use cgroups with it
+      - so for the runners I prefer rootful for "proper" gVisor
+      */
+      #DynamicUser = lib.mkForce false;
+      #User = lib.mkForce "gitlab-runner";
+      #Group = lib.mkForce "gitlab-runner";
+      /*
+      - add the gitlab-runner service to the podman group for access to the socket
+      - note that similarly to the docker group this is root-equivalent
+      - I still prefer this over running the unit as root for hardening
+      */
+      SupplementaryGroups = [ "podman" ];
+    };
+  };
+
+  environment.persistence."/persist".directories = [ "/var/lib/private/gitlab-runner" ];
+
+  /*
+  The gitlab-runner module enables Docker if there are any runners with the docker executor. As I
+  want to use podman instead, I have to manually force it disabled and set some workaround
+  options for podman support.
+
+  TODO: PR for gitlab-runner module to support using podman out-of-the-box
+  */
+  virtualisation.docker.enable = lib.mkForce false;
+
+  services.gitlab-runner = {
+    enable = true;
+    # give runners some time to finish up on termination of the runner service
+    gracefulTermination = true;
+    gracefulTimeout = "1min 30s";
+    settings = {
+      concurrent = 2;  # limits concurrent jobs across *all* runners
+      session_server = {
+        listen_address = "[::]:61035";
+        advertise_address = "[::]:61035";
+      };
+    };
+    services.default = {
+      # TODO pull always?
+      description = "sapphire-podman";
+      executor = "docker";
+      # sets CI_SERVER_URL and CI_SERVER_TOKEN
+      authenticationTokenConfigFile = config.age.secrets.server-gitlab-runner-podman.path;
+      dockerImage = "public.ecr.aws/docker/library/alpine:latest";
+      registrationFlags = [
+        /*
+        - we can just use the podman socket here
+        - no need for `virtualisation.dockerSocket.podman.dockerSocket.enable`
+        - that just symlinks the podman socket at /run/docker.sock
+        */
+        "--docker-host unix:///run/podman/podman.sock"
+        /*
+        TODO this is ignored??
+        */
+        "--docker-runtime runsc"
+      ];
+      #dockerVolumes = [ "/cache" ]; TODO
+      # seems to be default anyway...
+    };
+  };
+}
diff --git a/nixos/users.nix b/nixos/users.nix
index 850d3486dd7028e2c8a8b0eb1702f1446d3eacaf..bde0ddd9d5e3ee104fd310649a41712e731f374e 100644
--- a/nixos/users.nix
+++ b/nixos/users.nix
@@ -12,7 +12,15 @@ config for users and home-manager
     groups.users.gid = 100;
     users = {
       eisfunke = {
+        # hardcode ids to ensure determinism
         uid = 1000;
+        subUidRanges = [
+          { startUid = 231072; count = 65536; }
+        ];
+        subGidRanges = [
+          { startGid = 231072; count = 65536; }
+        ];
+
         description = "Nicolas Lenz";
         isNormalUser = true;
         extraGroups = [
@@ -35,6 +43,14 @@ config for users and home-manager
       privileges.
       */
       deploy = {
+        # hardcode ids to ensure determinism
+        uid = 1001;
+        subUidRanges = [
+          { startUid = 165536; count = 65536; }
+        ];
+        subGidRanges = [
+          { startGid = 165536; count = 65536; }
+        ];
         isNormalUser = true;
         extraGroups = [ "wheel" ];
         openssh.authorizedKeys.keys = config.users.users.eisfunke.openssh.authorizedKeys.keys;
diff --git a/res/secrets/server/gitlab-runner-podman.age b/res/secrets/server/gitlab-runner-podman.age
new file mode 100644
index 0000000000000000000000000000000000000000..14e82e16678b4ec55cb776ad6a2256875496245d
Binary files /dev/null and b/res/secrets/server/gitlab-runner-podman.age differ
diff --git a/secrets.nix b/secrets.nix
index e88b1d46d39b0e5e3e49b1a214f4a0380248c55e..078c935ceaf80eb234df3152763136ee2aa73ea5 100644
--- a/secrets.nix
+++ b/secrets.nix
@@ -155,6 +155,7 @@ in {
   "res/secrets/server/gitlab-omni-eisfunkeauth.age".publicKeys = keySets.server;
   "res/secrets/server/gitlab-pages.age".publicKeys = keySets.server;
   "res/secrets/server/gitlab-pages-client.age".publicKeys = keySets.server;
+  "res/secrets/server/gitlab-runner-podman.age".publicKeys = keySets.server;
 
   /*
   Secrets used by the CI runner microvm.