about summary refs log tree commit diff
path: root/ops/modules
diff options
context:
space:
mode:
Diffstat (limited to 'ops/modules')
-rw-r--r--ops/modules/.skip-subtree1
-rw-r--r--ops/modules/README.md7
-rw-r--r--ops/modules/atward.nix38
-rw-r--r--ops/modules/auto-deploy.nix104
-rw-r--r--ops/modules/automatic-gc.nix92
-rw-r--r--ops/modules/cgit.nix55
-rw-r--r--ops/modules/clbot.nix82
-rw-r--r--ops/modules/default-imports.nix14
-rw-r--r--ops/modules/default.nix2
-rw-r--r--ops/modules/depot-inbox.nix148
-rw-r--r--ops/modules/depot-replica.nix45
-rw-r--r--ops/modules/gerrit-queue.nix52
-rw-r--r--ops/modules/irccat.nix62
-rw-r--r--ops/modules/josh.nix33
-rw-r--r--ops/modules/journaldriver.nix26
-rw-r--r--ops/modules/known-hosts.nix21
-rw-r--r--ops/modules/monorepo-gerrit.nix174
-rw-r--r--ops/modules/nixery.nix44
-rw-r--r--ops/modules/open_eid.nix54
-rw-r--r--ops/modules/owothia.nix68
-rw-r--r--ops/modules/panettone.nix119
-rw-r--r--ops/modules/paroxysm.nix28
-rw-r--r--ops/modules/prometheus-fail2ban-exporter.nix52
-rw-r--r--ops/modules/quassel.nix83
-rw-r--r--ops/modules/restic.nix62
-rw-r--r--ops/modules/smtprelay.nix61
-rw-r--r--ops/modules/sourcegraph.nix60
-rw-r--r--ops/modules/tvl-buildkite.nix80
-rw-r--r--ops/modules/tvl-cache.nix19
-rw-r--r--ops/modules/tvl-headscale.nix62
-rw-r--r--ops/modules/tvl-slapd/default.nix81
-rw-r--r--ops/modules/tvl-users.nix85
-rw-r--r--ops/modules/v4l2loopback.nix12
-rw-r--r--ops/modules/www/atward.tvl.fyi.nix33
-rw-r--r--ops/modules/www/auth.tvl.fyi.nix28
-rw-r--r--ops/modules/www/b.tvl.fyi.nix32
-rw-r--r--ops/modules/www/base.nix41
-rw-r--r--ops/modules/www/cache.tvl.su.nix31
-rw-r--r--ops/modules/www/cl.tvl.fyi.nix34
-rw-r--r--ops/modules/www/code.tvl.fyi.nix65
-rw-r--r--ops/modules/www/cs.tvl.fyi.nix31
-rw-r--r--ops/modules/www/deploys.tvl.fyi.nix22
-rw-r--r--ops/modules/www/grep.tvl.fyi.nix22
-rw-r--r--ops/modules/www/inbox.tvl.su.nix31
-rw-r--r--ops/modules/www/nixery.dev.nix21
-rw-r--r--ops/modules/www/self-redirect.nix27
-rw-r--r--ops/modules/www/signup.tvl.fyi.nix19
-rw-r--r--ops/modules/www/static.tvl.fyi.nix42
-rw-r--r--ops/modules/www/status.tvl.su.nix25
-rw-r--r--ops/modules/www/tazj.in.nix49
-rw-r--r--ops/modules/www/todo.tvl.fyi.nix25
-rw-r--r--ops/modules/www/tvix.dev.nix39
-rw-r--r--ops/modules/www/tvixbolt.tvl.su.nix19
-rw-r--r--ops/modules/www/tvl.fyi.nix47
-rw-r--r--ops/modules/www/tvl.su.nix20
-rw-r--r--ops/modules/www/wigglydonke.rs.nix15
-rw-r--r--ops/modules/yandex-cloud.nix78
57 files changed, 2722 insertions, 0 deletions
diff --git a/ops/modules/.skip-subtree b/ops/modules/.skip-subtree
new file mode 100644
index 000000000000..09520f8c831f
--- /dev/null
+++ b/ops/modules/.skip-subtree
@@ -0,0 +1 @@
+NixOS modules are not readTree compatible.
diff --git a/ops/modules/README.md b/ops/modules/README.md
new file mode 100644
index 000000000000..595b4c3344c6
--- /dev/null
+++ b/ops/modules/README.md
@@ -0,0 +1,7 @@
+NixOS modules
+=============
+
+This folder contains various NixOS modules shared by our NixOS
+configurations.
+
+It is not read by `readTree`.
diff --git a/ops/modules/atward.nix b/ops/modules/atward.nix
new file mode 100644
index 000000000000..f345a08e3131
--- /dev/null
+++ b/ops/modules/atward.nix
@@ -0,0 +1,38 @@
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.atward;
+  description = "atward - (attempt to) cleverly route queries";
+in
+{
+  options.services.depot.atward = {
+    enable = lib.mkEnableOption description;
+
+    host = lib.mkOption {
+      type = lib.types.str;
+      default = "[::1]";
+      description = "Host on which atward should listen";
+    };
+
+    port = lib.mkOption {
+      type = lib.types.int;
+      default = 28973;
+      description = "Port on which atward should listen";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.atward = {
+      inherit description;
+      script = "${depot.web.atward}/bin/atward";
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        DynamicUser = true;
+        Restart = "always";
+      };
+
+      environment.ATWARD_LISTEN_ADDRESS = "${cfg.host}:${toString cfg.port}";
+    };
+  };
+}
diff --git a/ops/modules/auto-deploy.nix b/ops/modules/auto-deploy.nix
new file mode 100644
index 000000000000..c504906b2b94
--- /dev/null
+++ b/ops/modules/auto-deploy.nix
@@ -0,0 +1,104 @@
+# Defines a service for automatically and periodically calling depot's
+# rebuild-system on a NixOS machine.
+#
+# Deploys can be stopped in emergency situations by creating an empty
+# file called `stop` in the state directory of the auto-deploy service
+# (typically /var/lib/auto-deploy).
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.auto-deploy;
+  description = "to automatically rebuild the current system's NixOS config from the latest checkout of depot";
+
+  rebuild-system = depot.ops.nixos.rebuildSystemWith "$STATE_DIRECTORY/deploy";
+  deployScript = pkgs.writeShellScript "auto-deploy" ''
+    set -ueo pipefail
+
+    if [[ $EUID -ne 0 ]]; then
+      echo "Oh no! Only root is allowed to run auto-deploy!" >&2
+      exit 1
+    fi
+
+    if [[ -f $STATE_DIRECTORY/stop ]]; then
+      echo "stop file exists in $STATE_DIRECTORY, not deploying!" >&2
+      exit 1
+    fi
+
+    readonly depot=$STATE_DIRECTORY/depot.git
+    readonly deploy=$STATE_DIRECTORY/deploy
+    readonly git="git -C $depot"
+
+    # find-or-create depot
+    if [ ! -d $depot ]; then
+      # cannot use $git here because $depot doesn't exist
+      git clone --bare ${cfg.git-remote} $depot
+    fi
+
+    function cleanup() {
+      $git worktree remove $deploy
+    }
+    trap cleanup EXIT
+
+    $git fetch origin
+    $git worktree add --force $deploy FETCH_HEAD
+    # unsure why, but without this switch-to-configuration attempts to install
+    # NixOS in $STATE_DIRECTORY
+    (cd / && ${rebuild-system}/bin/rebuild-system)
+  '';
+in
+{
+  options.services.depot.auto-deploy = {
+    enable = lib.mkEnableOption description;
+
+    git-remote = lib.mkOption {
+      type = lib.types.str;
+      default = "https://cl.tvl.fyi/depot.git";
+      description = ''
+        The (possibly remote) repository from which to clone as specified by the
+        GIT URLS section of `man git-clone`.
+      '';
+    };
+
+    interval = lib.mkOption {
+      type = lib.types.str;
+      example = "1h";
+      description = ''
+        Interval between Nix builds, specified in systemd.time(7) format.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.auto-deploy = {
+      inherit description;
+      script = "${deployScript}";
+      path = with pkgs; [
+        bash
+        git
+        gnutar
+        gzip
+      ];
+      after = [ "network-online.target" ];
+      wants = [ "network-online.target" ];
+
+      # We need to prevent NixOS from interrupting us while it attempts to
+      # restart systemd units.
+      restartIfChanged = false;
+
+      serviceConfig = {
+        Type = "oneshot";
+        StateDirectory = "auto-deploy";
+      };
+    };
+
+    systemd.timers.auto-deploy = {
+      inherit description;
+      wantedBy = [ "multi-user.target" ];
+
+      timerConfig = {
+        OnActiveSec = "1";
+        OnUnitActiveSec = cfg.interval;
+      };
+    };
+  };
+}
diff --git a/ops/modules/automatic-gc.nix b/ops/modules/automatic-gc.nix
new file mode 100644
index 000000000000..ad53a63f7f16
--- /dev/null
+++ b/ops/modules/automatic-gc.nix
@@ -0,0 +1,92 @@
+# Defines a service for automatically collecting Nix garbage
+# periodically, without relying on the (ostensibly broken) Nix options
+# for min/max space available.
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.automatic-gc;
+  description = "Automatically collect Nix garbage";
+
+  GiBtoKiB = n: n * 1024 * 1024;
+  GiBtoBytes = n: n * 1024 * 1024 * 1024;
+
+  gcScript = pkgs.writeShellScript "automatic-nix-gc" ''
+    set -ueo pipefail
+
+    readonly MIN_THRESHOLD_KIB="${toString (GiBtoKiB cfg.diskThreshold)}"
+    readonly MAX_FREED_BYTES="${toString (GiBtoBytes cfg.maxFreed)}"
+    readonly GEN_THRESHOLD="${cfg.preserveGenerations}"
+    readonly AVAILABLE_KIB=$(df --sync /nix --output=avail | tail -n1)
+
+    if [ "''${AVAILABLE_KIB}" -lt "''${MIN_THRESHOLD_KIB}" ]; then
+      echo "Have ''${AVAILABLE_KIB} KiB, but want ''${MIN_THRESHOLD_KIB} KiB."
+      echo "Triggering Nix garbage collection up to ''${MAX_FREED_BYTES} bytes."
+      set -x
+      ${config.nix.package}/bin/nix-collect-garbage \
+        --delete-older-than "''${GEN_THRESHOLD}" \
+        --max-freed "''${MAX_FREED_BYTES}"
+    else
+      echo "Skipping GC, enough space available"
+    fi
+  '';
+in
+{
+  options.services.depot.automatic-gc = {
+    enable = lib.mkEnableOption description;
+
+    interval = lib.mkOption {
+      type = lib.types.str;
+      example = "1h";
+      description = ''
+        Interval between garbage collection runs, specified in
+        systemd.time(7) format.
+      '';
+    };
+
+    diskThreshold = lib.mkOption {
+      type = lib.types.int;
+      example = "100";
+      description = ''
+        Minimum amount of space that needs to be available (in GiB) on
+        the partition holding /nix. Garbage collection is triggered if
+        it falls below this.
+      '';
+    };
+
+    maxFreed = lib.mkOption {
+      type = lib.types.int;
+      example = "420";
+      description = ''
+        Maximum amount of space to free in a single GC run, in GiB.
+      '';
+    };
+
+    preserveGenerations = lib.mkOption {
+      type = lib.types.str;
+      default = "90d";
+      description = ''
+        Preserve NixOS generations younger than the specified value,
+        in the format expected by nix-collect-garbage(1).
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.automatic-gc = {
+      inherit description;
+      script = "${gcScript}";
+      serviceConfig.Type = "oneshot";
+    };
+
+    systemd.timers.automatic-gc = {
+      inherit description;
+      requisite = [ "nix-daemon.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+      timerConfig = {
+        OnActiveSec = "1";
+        OnUnitActiveSec = cfg.interval;
+      };
+    };
+  };
+}
diff --git a/ops/modules/cgit.nix b/ops/modules/cgit.nix
new file mode 100644
index 000000000000..fc3f17158579
--- /dev/null
+++ b/ops/modules/cgit.nix
@@ -0,0 +1,55 @@
+# Configuration for running the TVL cgit instance using thttpd.
+{ config, depot, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.cgit;
+
+  userConfig =
+    if builtins.isNull cfg.user then {
+      DynamicUser = true;
+    } else {
+      User = cfg.user;
+      Group = cfg.user;
+    };
+in
+{
+  options.services.depot.cgit = with lib; {
+    enable = mkEnableOption "Run cgit web interface for depot";
+
+    port = mkOption {
+      description = "Port on which cgit should listen";
+      type = types.int;
+      default = 2448;
+    };
+
+    repo = mkOption {
+      description = "Path to depot's .git folder on the machine";
+      type = types.str;
+      default = "/var/lib/gerrit/git/depot.git/";
+    };
+
+    user = mkOption {
+      description = ''
+        User to use for the cgit service. It is expected that this is
+        also the name of the user's primary group.
+      '';
+
+      type = with types; nullOr str;
+      default = null;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.cgit = {
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        Restart = "on-failure";
+
+        ExecStart = depot.web.cgit-tvl.override {
+          inherit (cfg) port repo;
+        };
+      } // userConfig;
+    };
+  };
+}
diff --git a/ops/modules/clbot.nix b/ops/modules/clbot.nix
new file mode 100644
index 000000000000..bdddff6c810b
--- /dev/null
+++ b/ops/modules/clbot.nix
@@ -0,0 +1,82 @@
+# Module that configures CLBot, our Gerrit->IRC info bridge.
+{ depot, config, lib, pkgs, ... }:
+
+let
+  inherit (builtins) attrValues concatStringsSep mapAttrs readFile;
+  inherit (pkgs) runCommand;
+
+  inherit (lib)
+    listToAttrs
+    mkEnableOption
+    mkIf
+    mkOption
+    removeSuffix
+    types;
+
+  description = "Bot to forward CL notifications";
+  cfg = config.services.depot.clbot;
+
+  mkFlags = flags:
+    concatStringsSep " "
+      (attrValues (mapAttrs (key: value: "-${key} \"${toString value}\"") flags));
+
+  # Escapes a unit name for use in systemd
+  systemdEscape = name: removeSuffix "\n" (readFile (runCommand "unit-name" { } ''
+    ${pkgs.systemd}/bin/systemd-escape '${name}' >> $out
+  ''));
+
+  mkUnit = flags: channel: {
+    name = "clbot-${systemdEscape channel}";
+    value = {
+      description = "${description} to ${channel}";
+      wantedBy = [ "multi-user.target" ];
+
+      script = "${depot.fun.clbot}/bin/clbot ${mkFlags (cfg.flags // {
+        irc_channel = channel;
+      })} -alsologtostderr";
+
+      serviceConfig = {
+        User = "clbot";
+        EnvironmentFile = cfg.secretsFile;
+        Restart = "always";
+      };
+    };
+  };
+in
+{
+  options.services.depot.clbot = {
+    enable = mkEnableOption description;
+
+    flags = mkOption {
+      type = types.attrsOf types.str;
+      description = "Key value pairs for command line flags";
+    };
+
+    channels = mkOption {
+      type = with types; listOf str;
+      description = "Channels in which to post (generates one unit per channel)";
+    };
+
+    secretsFile = mkOption {
+      type = types.str;
+      description = "EnvironmentFile from which to load secrets";
+      default = config.age.secretsDir + "/clbot";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # This does not use DynamicUser because we need to make some files
+    # (notably the SSH private key) readable by this user outside of
+    # the module.
+    users = {
+      groups.clbot = { };
+
+      users.clbot = {
+        group = "clbot";
+        isSystemUser = true;
+      };
+    };
+
+    systemd.services = listToAttrs (map (mkUnit cfg.flags) cfg.channels);
+  };
+}
diff --git a/ops/modules/default-imports.nix b/ops/modules/default-imports.nix
new file mode 100644
index 000000000000..11514a437a42
--- /dev/null
+++ b/ops/modules/default-imports.nix
@@ -0,0 +1,14 @@
+{ depot, ... }:
+
+# Default set of modules that are imported in all Depot nixos systems
+#
+# All modules here should be properly gated behind a `lib.mkEnableOption` with a
+# `lib.mkIf` for the config.
+
+{
+  imports = [
+    ./automatic-gc.nix
+    ./auto-deploy.nix
+    ./tvl-cache.nix
+  ];
+}
diff --git a/ops/modules/default.nix b/ops/modules/default.nix
new file mode 100644
index 000000000000..d747e8e1319a
--- /dev/null
+++ b/ops/modules/default.nix
@@ -0,0 +1,2 @@
+# Make readTree happy at this level.
+_: { }
diff --git a/ops/modules/depot-inbox.nix b/ops/modules/depot-inbox.nix
new file mode 100644
index 000000000000..14fc646a9a43
--- /dev/null
+++ b/ops/modules/depot-inbox.nix
@@ -0,0 +1,148 @@
+# public-inbox configuration for depot@tvl.su
+#
+# The account itself is a Yandex 360 account in the tvl.su organisation, which
+# is accessed via IMAP. Yandex takes care of spam filtering for us, so there is
+# no particular SpamAssassin or other configuration.
+{ config, depot, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.inbox;
+
+  imapConfig = pkgs.writeText "offlineimaprc" ''
+    [general]
+    accounts = depot
+
+    [Account depot]
+    localrepository = Local
+    remoterepository = Remote
+
+    [Repository Local]
+    type = Maildir
+    localfolders = /var/lib/public-inbox/depot-imap
+
+    [Repository Remote]
+    type = IMAP
+    ssl = yes
+    sslcacertfile = /etc/ssl/certs/ca-bundle.crt
+    remotehost = imap.yandex.ru
+    remoteuser = depot@tvl.su
+    remotepassfile = /var/run/agenix/depot-inbox-imap
+  '';
+in
+{
+  options.services.depot.inbox = with lib; {
+    enable = mkEnableOption "Enable public-inbox for depot@tvl.su";
+
+    depotPath = mkOption {
+      description = "path to local depot replica";
+      type = types.str;
+      default = "/var/lib/depot";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # Having nginx *and* other services use ACME certificates for the
+    # same hostname is unsupported in NixOS without resorting to doing
+    # all ACME configuration manually.
+    #
+    # To work around this, we duplicate the TLS certificate used by
+    # nginx to a location that is readable by public-inbox daemons.
+    systemd.services.inbox-cert-sync = {
+      startAt = "daily";
+
+      script = ''
+        ${pkgs.coreutils}/bin/install -D -g ${config.users.groups."public-inbox".name} -m 0440 \
+          /var/lib/acme/inbox.tvl.su/fullchain.pem /var/lib/public-inbox/tls/fullchain.pem
+
+        ${pkgs.coreutils}/bin/install -D -g ${config.users.groups."public-inbox".name} -m 0440 \
+          /var/lib/acme/inbox.tvl.su/key.pem /var/lib/public-inbox/tls/key.pem
+      '';
+    };
+
+    services.public-inbox = {
+      enable = true;
+
+      http.enable = true;
+      http.port = 8053;
+
+      imap = {
+        enable = true;
+        port = 993;
+        cert = "/var/lib/public-inbox/tls/fullchain.pem";
+        key = "/var/lib/public-inbox/tls/key.pem";
+      };
+
+      nntp = {
+        enable = true;
+        port = 563;
+        cert = "/var/lib/public-inbox/tls/fullchain.pem";
+        key = "/var/lib/public-inbox/tls/key.pem";
+      };
+
+      inboxes.depot = rec {
+        address = [
+          "depot@tvl.su" # primary address
+          "depot@tazj.in" # legacy address
+        ];
+
+        description = "TVL depot development (mail to depot@tvl.su)";
+        coderepo = [ "depot" ];
+        url = "https://inbox.tvl.su/depot";
+
+        watch = [
+          "maildir:/var/lib/public-inbox/depot-imap/INBOX/"
+        ];
+
+        newsgroup = "su.tvl.depot";
+      };
+
+      settings.coderepo.depot = {
+        dir = cfg.depotPath;
+        cgitUrl = "https://code.tvl.fyi";
+      };
+
+      settings.publicinbox = {
+        wwwlisting = "all";
+        nntpserver = [ "inbox.tvl.su" ];
+        imapserver = [ "inbox.tvl.su" ];
+
+        depot.obfuscate = true;
+        noObfuscate = [
+          "tvl.su"
+          "tvl.fyi"
+        ];
+      };
+    };
+
+    networking.firewall.allowedTCPPorts = [
+      993 # imap
+      563 # nntp
+    ];
+
+    age.secrets.depot-inbox-imap = {
+      file = depot.ops.secrets."depot-inbox-imap.age";
+      mode = "0440";
+      group = config.users.groups."public-inbox".name;
+    };
+
+    systemd.services.offlineimap-depot = {
+      description = "download mail for depot@tvl.su";
+      wantedBy = [ "multi-user.target" ];
+      startAt = "minutely";
+
+      script = ''
+        mkdir -p /var/lib/public-inbox/depot-imap
+        ${pkgs.offlineimap}/bin/offlineimap -c ${imapConfig}
+      '';
+
+      serviceConfig = {
+        Type = "oneshot";
+
+        # Run in the same user context as public-inbox itself to avoid
+        # permissions trouble.
+        User = config.users.users."public-inbox".name;
+        Group = config.users.groups."public-inbox".name;
+      };
+    };
+  };
+}
diff --git a/ops/modules/depot-replica.nix b/ops/modules/depot-replica.nix
new file mode 100644
index 000000000000..b71f10409a2b
--- /dev/null
+++ b/ops/modules/depot-replica.nix
@@ -0,0 +1,45 @@
+# Configuration for receiving a depot replica from Gerrit's
+# replication plugin.
+#
+# This only prepares the user and folder for receiving the replica,
+# but Gerrit configuration still needs to be modified in addition.
+{ config, depot, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.replica;
+in
+{
+  options.services.depot.replica = with lib; {
+    enable = mkEnableOption "Receive depot git replica from Gerrit";
+
+    key = mkOption {
+      description = "Public key to use for replication";
+      type = types.str;
+      default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFFab9O1xaQ1TCyn+CxmXHexdlLzURREG+UR3Qdi3BvH";
+    };
+
+    path = mkOption {
+      description = "Replication destination path (will be created)";
+      type = types.str;
+      default = "/var/lib/depot";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    users.groups.depot = { };
+
+    users.users.depot = {
+      group = "depot";
+      isSystemUser = true;
+      createHome = true;
+      home = cfg.path;
+      homeMode = "755"; # everyone can read depot
+      openssh.authorizedKeys.keys = lib.singleton cfg.key;
+      shell = pkgs.bashInteractive; # gerrit needs to run shell commands
+    };
+
+    environment.systemPackages = [
+      pkgs.git
+    ];
+  };
+}
diff --git a/ops/modules/gerrit-queue.nix b/ops/modules/gerrit-queue.nix
new file mode 100644
index 000000000000..4468bcf1c5f9
--- /dev/null
+++ b/ops/modules/gerrit-queue.nix
@@ -0,0 +1,52 @@
+# Configuration for the Gerrit autosubmit bot (//third_party/gerrit-queue)
+{ depot, pkgs, config, lib, ... }:
+
+let
+  cfg = config.services.depot.gerrit-queue;
+  description = "gerrit-queue - autosubmit bot for Gerrit";
+  mkStringOption = default: lib.mkOption {
+    inherit default;
+    type = lib.types.str;
+  };
+in
+{
+  options.services.depot.gerrit-queue = {
+    enable = lib.mkEnableOption description;
+    gerritUrl = mkStringOption "https://cl.tvl.fyi";
+    gerritProject = mkStringOption "depot";
+    gerritBranch = mkStringOption "canon";
+
+    interval = with lib; mkOption {
+      type = types.int;
+      default = 60;
+      description = "Interval (in seconds) for submit queue checks";
+    };
+
+    secretsFile = with lib; mkOption {
+      description = "Path to a systemd EnvironmentFile containing secrets";
+      default = config.age.secretsDir + "/gerrit-queue";
+      type = types.str;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.gerrit-queue = {
+      inherit description;
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = "${depot.third_party.gerrit-queue}/bin/gerrit-queue";
+        DynamicUser = true;
+        Restart = "always";
+        EnvironmentFile = cfg.secretsFile;
+      };
+
+      environment = {
+        GERRIT_URL = cfg.gerritUrl;
+        GERRIT_PROJECT = cfg.gerritProject;
+        GERRIT_BRANCH = cfg.gerritBranch;
+        SUBMIT_QUEUE_TRIGGER_INTERVAL = toString cfg.interval;
+      };
+    };
+  };
+}
diff --git a/ops/modules/irccat.nix b/ops/modules/irccat.nix
new file mode 100644
index 000000000000..0819c52a8de7
--- /dev/null
+++ b/ops/modules/irccat.nix
@@ -0,0 +1,62 @@
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.irccat;
+  description = "irccat - forward messages to IRC";
+
+  # irccat expects to read its configuration from the *current
+  # directory*, and its configuration contains secrets.
+  #
+  # To make this work we construct the JSON configuration file and
+  # then recursively merge it with an on-disk secret using jq on
+  # service launch.
+  configJson = pkgs.writeText "irccat.json" (builtins.toJSON cfg.config);
+
+  # Right now, merging configuration file with secrets and running the main
+  # application needs to happen both in ExecStart=, due to
+  # https://github.com/systemd/systemd/issues/19604#issuecomment-989279884
+  mergeAndLaunch = pkgs.writeShellScript "merge-irccat-config" ''
+    if [ ! -f "$CREDENTIALS_DIRECTORY/secrets" ]; then
+      echo "irccat secrets file is missing"
+      exit 1
+    fi
+
+    # jq's * is the recursive merge operator
+    ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${configJson} "$CREDENTIALS_DIRECTORY/secrets" \
+      > /var/lib/irccat/irccat.json
+
+    exec ${depot.third_party.irccat}/bin/irccat
+  '';
+in
+{
+  options.services.depot.irccat = {
+    enable = lib.mkEnableOption description;
+
+    config = lib.mkOption {
+      type = lib.types.attrs; # varying value types
+      description = "Configuration structure (unchecked!)";
+    };
+
+    secretsFile = lib.mkOption {
+      type = lib.types.str;
+      description = "Path to the secrets file to be merged";
+      default = config.age.secretsDir + "/irccat";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.irccat = {
+      inherit description;
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = "${mergeAndLaunch}";
+        DynamicUser = true;
+        StateDirectory = "irccat";
+        WorkingDirectory = "/var/lib/irccat";
+        LoadCredential = "secrets:${cfg.secretsFile}";
+        Restart = "always";
+      };
+    };
+  };
+}
diff --git a/ops/modules/josh.nix b/ops/modules/josh.nix
new file mode 100644
index 000000000000..4591ebf0f078
--- /dev/null
+++ b/ops/modules/josh.nix
@@ -0,0 +1,33 @@
+# Configures the public josh instance for serving the depot.
+{ config, depot, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.josh;
+in
+{
+  options.services.depot.josh = with lib; {
+    enable = mkEnableOption "Enable josh for serving the depot";
+
+    port = mkOption {
+      description = "Port on which josh should listen";
+      type = types.int;
+      default = 5674;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # Run josh for the depot.
+    systemd.services.josh = {
+      description = "josh - partial cloning of monorepos";
+      wantedBy = [ "multi-user.target" ];
+      path = [ pkgs.git pkgs.bash ];
+
+      serviceConfig = {
+        DynamicUser = true;
+        StateDirectory = "josh";
+        Restart = "always";
+        ExecStart = "${depot.third_party.josh}/bin/josh-proxy --no-background --local /var/lib/josh --port ${toString cfg.port} --remote https://cl.tvl.fyi/ --require-auth";
+      };
+    };
+  };
+}
diff --git a/ops/modules/journaldriver.nix b/ops/modules/journaldriver.nix
new file mode 100644
index 000000000000..0d6b0bcc7f63
--- /dev/null
+++ b/ops/modules/journaldriver.nix
@@ -0,0 +1,26 @@
+# Configures journaldriver to forward to the tvl-fyi GCP project from
+# TVL machines.
+{ config, depot, lib, pkgs, ... }:
+
+{
+  imports = [
+    (depot.third_party.agenix.src + "/modules/age.nix")
+  ];
+
+  age.secrets.journaldriver.file = depot.ops.secrets."journaldriver.age";
+
+  services.journaldriver = {
+    enable = true;
+    googleCloudProject = "tvl-fyi";
+    logStream = config.networking.hostName;
+  };
+
+  # Override the systemd service defined in the nixpkgs module to use
+  # the credentials provided by agenix.
+  systemd.services.journaldriver = {
+    serviceConfig = {
+      LoadCredential = "journaldriver.json:/run/agenix/journaldriver";
+      ExecStart = lib.mkForce "${pkgs.coreutils}/bin/env GOOGLE_APPLICATION_CREDENTIALS=\"\${CREDENTIALS_DIRECTORY}/journaldriver.json\" ${depot.ops.journaldriver}/bin/journaldriver";
+    };
+  };
+}
diff --git a/ops/modules/known-hosts.nix b/ops/modules/known-hosts.nix
new file mode 100644
index 000000000000..9ea689178e3b
--- /dev/null
+++ b/ops/modules/known-hosts.nix
@@ -0,0 +1,21 @@
+# Configure public keys for SSH hosts known to TVL.
+{ ... }:
+
+{
+  programs.ssh.knownHosts = {
+    whitby = {
+      hostNames = [ "whitby.tvl.fyi" "whitby.tvl.su" ];
+      publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILNh/w4BSKov0jdz3gKBc98tpoLta5bb87fQXWBhAl2I";
+    };
+
+    sanduny = {
+      hostNames = [ "sanduny.tvl.su" ];
+      publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOag0XhylaTVhmT6HB8EN2Fv5Ymrc4ZfypOXONUkykTX";
+    };
+
+    github = {
+      hostNames = [ "github.com" ];
+      publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";
+    };
+  };
+}
diff --git a/ops/modules/monorepo-gerrit.nix b/ops/modules/monorepo-gerrit.nix
new file mode 100644
index 000000000000..059dee57940b
--- /dev/null
+++ b/ops/modules/monorepo-gerrit.nix
@@ -0,0 +1,174 @@
+# Gerrit configuration for the TVL monorepo
+{ depot, pkgs, config, lib, ... }:
+
+let
+  cfg = config.services.gerrit;
+
+  besadiiWithConfig = name: pkgs.writeShellScript "besadii-whitby" ''
+    export BESADII_CONFIG=/run/agenix/gerrit-besadii-config
+    exec -a ${name} ${depot.ops.besadii}/bin/besadii "$@"
+  '';
+
+  gerritHooks = pkgs.runCommand "gerrit-hooks" { } ''
+    mkdir -p $out
+    ln -s ${besadiiWithConfig "change-merged"} $out/change-merged
+    ln -s ${besadiiWithConfig "patchset-created"} $out/patchset-created
+  '';
+in
+{
+  services.gerrit = {
+    enable = true;
+    listenAddress = "[::]:4778"; # 4778 - grrt
+    serverId = "4fdfa107-4df9-4596-8e0a-1d2bbdd96e36";
+
+    builtinPlugins = [
+      "download-commands"
+      "hooks"
+      "replication"
+    ];
+
+    plugins = with depot.third_party.gerrit_plugins; [
+      code-owners
+      oauth
+      depot.ops.gerrit-tvl
+    ];
+
+    package = depot.third_party.gerrit;
+
+    jvmHeapLimit = "4g";
+
+    # In some NixOS channel bump, the default version of OpenJDK has
+    # changed to one that is incompatible with our current version of
+    # Gerrit.
+    #
+    # TODO(tazjin): Update Gerrit and remove this when possible.
+    jvmPackage = pkgs.openjdk11_headless;
+
+    settings = {
+      core.packedGitLimit = "100m";
+      log.jsonLogging = true;
+      log.textLogging = false;
+      sshd.advertisedAddress = "code.tvl.fyi:29418";
+      hooks.path = "${gerritHooks}";
+      cache.web_sessions.maxAge = "3 months";
+      plugins.allowRemoteAdmin = false;
+      change.enableAttentionSet = true;
+      change.enableAssignee = false;
+
+      # Configures gerrit for being reverse-proxied by nginx as per
+      # https://gerrit-review.googlesource.com/Documentation/config-reverseproxy.html
+      gerrit = {
+        canonicalWebUrl = "https://cl.tvl.fyi";
+        docUrl = "/Documentation";
+      };
+
+      httpd.listenUrl = "proxy-https://${cfg.listenAddress}";
+
+      download.command = [
+        "checkout"
+        "cherry_pick"
+        "format_patch"
+        "pull"
+      ];
+
+      # Configure for cgit.
+      gitweb = {
+        type = "custom";
+        url = "https://code.tvl.fyi";
+        project = "/";
+        revision = "/commit/?id=\${commit}";
+        branch = "/log/?h=\${branch}";
+        tag = "/tag/?h=\${tag}";
+        roottree = "/tree/?h=\${commit}";
+        file = "/tree/\${file}?h=\${commit}";
+        filehistory = "/log/\${file}?h=\${branch}";
+        linkname = "cgit";
+      };
+
+      # Auto-link panettone bug links
+      commentlink.panettone = {
+        match = "b/(\\\\d+)";
+        html = "<a href=\"https://b.tvl.fyi/issues/$1\">b/$1</a>";
+      };
+
+      # Auto-link other CLs
+      commentlink.gerrit = {
+        match = "cl/(\\\\d+)";
+        html = "<a href=\"https://cl.tvl.fyi/$1\">cl/$1</a>";
+      };
+
+      # Configures integration with Keycloak, which then integrates with a
+      # variety of backends.
+      auth.type = "OAUTH";
+      plugin.gerrit-oauth-provider-keycloak-oauth = {
+        root-url = "https://auth.tvl.fyi";
+        realm = "TVL";
+        client-id = "gerrit";
+        # client-secret is set in /var/lib/gerrit/etc/secure.config.
+      };
+
+      plugin.code-owners = {
+        # A Code-Review +2 vote is required from a code owner.
+        requiredApproval = "Code-Review+2";
+        # The OWNERS check can be overriden using an Owners-Override vote.
+        overrideApproval = "Owners-Override+1";
+        # People implicitly approve their own changes automatically.
+        enableImplicitApprovals = "TRUE";
+      };
+
+      # Allow users to add additional email addresses to their accounts.
+      oauth.allowRegisterNewEmail = true;
+
+      # Use Gerrit's built-in HTTP passwords, rather than trying to use the
+      # password against the backing OAuth provider.
+      auth.gitBasicAuthPolicy = "HTTP";
+
+      # Email sending (emails are relayed via the tazj.in domain's
+      # GSuite currently).
+      #
+      # Note that sendemail.smtpPass is stored in
+      # $site_path/etc/secure.config and is *not* controlled by Nix.
+      #
+      # Receiving email is not currently supported.
+      sendemail = {
+        enable = true;
+        html = false;
+        connectTimeout = "10sec";
+        from = "TVL Code Review <tvlbot@tazj.in>";
+        includeDiff = true;
+        smtpEncryption = "none";
+        smtpServer = "localhost";
+        smtpServerPort = 2525;
+      };
+    };
+
+    # Replication of the depot repository to secondary machines, for
+    # serving cgit/josh.
+    replicationSettings = {
+      gerrit.replicateOnStartup = true;
+
+      remote.sanduny = {
+        url = "depot@sanduny.tvl.su:/var/lib/depot";
+        projects = "depot";
+      };
+    };
+  };
+
+  systemd.services.gerrit = {
+    serviceConfig = {
+      # There seems to be no easy way to get `DynamicUser` to play
+      # well with other services (e.g. by using SupplementaryGroups,
+      # which seem to have no effect) so we force the DynamicUser
+      # setting for the Gerrit service to be disabled and reuse the
+      # existing 'git' user.
+      DynamicUser = lib.mkForce false;
+      User = "git";
+      Group = "git";
+    };
+  };
+
+  services.depot.restic = {
+    paths = [ "/var/lib/gerrit" ];
+    exclude = [ "/var/lib/gerrit/tmp" ];
+  };
+}
diff --git a/ops/modules/nixery.nix b/ops/modules/nixery.nix
new file mode 100644
index 000000000000..29da46cc1de5
--- /dev/null
+++ b/ops/modules/nixery.nix
@@ -0,0 +1,44 @@
+# NixOS module to run Nixery, currently with local-storage as the
+# backend for storing/serving image layers.
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.nixery;
+  description = "Nixery - container images on-demand";
+  nixpkgsSrc = depot.third_party.sources.nixpkgs-stable;
+  storagePath = "/var/lib/nixery/${nixpkgsSrc.rev}";
+in
+{
+  options.services.depot.nixery = {
+    enable = lib.mkEnableOption description;
+
+    port = lib.mkOption {
+      type = lib.types.int;
+      default = 45243; # "image"
+      description = "Port on which Nixery should listen";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.nixery = {
+      inherit description;
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        DynamicUser = true;
+        StateDirectory = "nixery";
+        Restart = "always";
+        ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p ${storagePath}";
+        ExecStart = "${depot.tools.nixery.nixery}/bin/server";
+      };
+
+      environment = {
+        PORT = toString cfg.port;
+        NIXERY_PKGS_PATH = nixpkgsSrc.outPath;
+        NIXERY_STORAGE_BACKEND = "filesystem";
+        NIX_TIMEOUT = "60"; # seconds
+        STORAGE_PATH = storagePath;
+      };
+    };
+  };
+}
diff --git a/ops/modules/open_eid.nix b/ops/modules/open_eid.nix
new file mode 100644
index 000000000000..3a86e9e41690
--- /dev/null
+++ b/ops/modules/open_eid.nix
@@ -0,0 +1,54 @@
+# NixOS module to configure the Estonian e-ID software.
+{ pkgs, ... }:
+
+{
+  services.pcscd.enable = true;
+
+  # Tell p11-kit to load/proxy opensc-pkcs11.so, providing all available slots
+  # (PIN1 for authentication/decryption, PIN2 for signing).
+  environment.etc."pkcs11/modules/opensc-pkcs11".text = ''
+    module: ${pkgs.opensc}/lib/opensc-pkcs11.so
+  '';
+
+  # Configure Firefox (in case users set `programs.firefox.enable = true;`)
+  programs.firefox = {
+    # Allow a possibly installed "Web eID" extension to do native messaging with
+    # the "web-eid-app" native component.
+    # Users not using `programs.firefox.enable` can override their firefox
+    # derivation, by setting `extraNativeMessagingHosts = [ pkgs.web-eid-app ]`.
+    nativeMessagingHosts.euwebid = true;
+    # Configure Firefox to load smartcards via p11kit-proxy.
+    # Users not using `programs.firefox.enable` can override their firefox
+    # derivation, by setting
+    # `extraPolicies.SecurityDevices.p11-kit-proxy "${pkgs.p11-kit}/lib/p11-kit-proxy.so"`.
+    policies.SecurityDevices.p11-kit-proxy = "${pkgs.p11-kit}/lib/p11-kit-proxy.so";
+  };
+
+  # Chromium users need a symlink to their (slightly different) .json file
+  # in the native messaging hosts' manifest file location.
+  environment.etc."chromium/native-messaging-hosts/eu.webeid.json".source = "${pkgs.web-eid-app}/share/web-eid/eu.webeid.json";
+  environment.etc."opt/chrome/native-messaging-hosts/eu.webeid.json".source = "${pkgs.web-eid-app}/share/web-eid/eu.webeid.json";
+
+  environment.systemPackages = with pkgs; [
+    libdigidocpp.bin # provides digidoc-tool(1)
+    qdigidoc
+
+    # Wrapper script to tell to Chrome/Chromium to use p11-kit-proxy to load
+    # security devices, so they can be used for TLS client auth.
+    # Each user needs to run this themselves, it does not work on a system level
+    # due to a bug in Chromium:
+    #
+    # https://bugs.chromium.org/p/chromium/issues/detail?id=16387
+    #
+    # Firefox users can just set
+    # extraPolicies.SecurityDevices.p11-kit-proxy "${pkgs.p11-kit}/lib/p11-kit-proxy.so";
+    # when overriding the firefox derivation.
+    (pkgs.writeShellScriptBin "setup-browser-eid" ''
+      NSSDB="''${HOME}/.pki/nssdb"
+      mkdir -p ''${NSSDB}
+
+      ${pkgs.nssTools}/bin/modutil -force -dbdir sql:$NSSDB -add p11-kit-proxy \
+        -libfile ${pkgs.p11-kit}/lib/p11-kit-proxy.so
+    '')
+  ];
+}
diff --git a/ops/modules/owothia.nix b/ops/modules/owothia.nix
new file mode 100644
index 000000000000..b9746c172090
--- /dev/null
+++ b/ops/modules/owothia.nix
@@ -0,0 +1,68 @@
+# Run the owothia IRC bot.
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.owothia;
+  description = "owothia - i'm a service owo";
+in
+{
+  options.services.depot.owothia = {
+    enable = lib.mkEnableOption description;
+
+    secretsFile = lib.mkOption {
+      type = lib.types.str;
+      description = "File path from which systemd should read secrets";
+      default = config.age.secretsDir + "/owothia";
+    };
+
+    owoChance = lib.mkOption {
+      type = lib.types.int;
+      description = "How likely is owo?";
+      default = 200;
+    };
+
+    ircServer = lib.mkOption {
+      type = lib.types.str;
+      description = "IRC server hostname";
+    };
+
+    ircPort = lib.mkOption {
+      type = lib.types.int;
+      description = "IRC server port";
+    };
+
+    ircIdent = lib.mkOption {
+      type = lib.types.str;
+      description = "IRC username";
+      default = "owothia";
+    };
+
+    ircChannels = lib.mkOption {
+      type = with lib.types; listOf str;
+      description = "IRC channels to join";
+      default = [ "#tvl" ];
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.owothia = {
+      inherit description;
+      script = "${depot.fun.owothia}/bin/owothia";
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        DynamicUser = true;
+        Restart = "always";
+        EnvironmentFile = cfg.secretsFile;
+      };
+
+      environment = {
+        OWO_CHANCE = toString cfg.owoChance;
+        IRC_SERVER = cfg.ircServer;
+        IRC_PORT = toString cfg.ircPort;
+        IRC_IDENT = cfg.ircIdent;
+        IRC_CHANNELS = builtins.toJSON cfg.ircChannels;
+      };
+    };
+  };
+}
diff --git a/ops/modules/panettone.nix b/ops/modules/panettone.nix
new file mode 100644
index 000000000000..e23dd028ab3c
--- /dev/null
+++ b/ops/modules/panettone.nix
@@ -0,0 +1,119 @@
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.panettone;
+in
+{
+  options.services.depot.panettone = with lib; {
+    enable = mkEnableOption "Panettone issue tracker";
+
+    port = mkOption {
+      description = "Port on which Panettone should listen";
+      type = types.int;
+      default = 7268;
+    };
+
+    dbHost = mkOption {
+      description = "Postgresql host to connect to for Panettone";
+      type = types.str;
+      default = "localhost";
+    };
+
+    dbName = mkOption {
+      description = "Name of the database for Panettone";
+      type = types.str;
+      default = "panettone";
+    };
+
+    dbUser = mkOption {
+      description = "Name of the database user for Panettone";
+      type = types.str;
+      default = "panettone";
+    };
+
+    secretsFile = mkOption {
+      description = ''
+        Path to a file containing secrets, in the format accepted
+        by systemd's EnvironmentFile
+      '';
+      type = types.str;
+      default = config.age.secretsDir + "/panettone";
+    };
+
+    irccatHost = mkOption {
+      description = "Hostname for the irccat instance";
+      type = types.str;
+      default = "localhost";
+    };
+
+    irccatPort = mkOption {
+      description = "Port for the irccat instance";
+      type = types.int;
+      default = 4722;
+    };
+
+    irccatChannel = mkOption {
+      description = "IRC channels to post to via irccat";
+      type = types.str;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    assertions = [{
+      assertion =
+        cfg.dbHost != "localhost" || config.services.postgresql.enable;
+      message = "Panettone requires a postgresql database";
+    }
+      {
+        assertion =
+          cfg.dbHost != "localhost" || config.services.postgresql.enableTCPIP;
+        message = "Panettone can only connect to the postgresql database over TCP";
+      }
+      {
+        assertion =
+          cfg.dbHost != "localhost" || (lib.any
+            (user: user.name == cfg.dbUser)
+            config.services.postgresql.ensureUsers);
+        message = "Panettone requires a database user";
+      }
+      {
+        assertion =
+          cfg.dbHost != "localhost" || (lib.any
+            (db: db == cfg.dbName)
+            config.services.postgresql.ensureDatabases);
+        message = "Panettone requires a database";
+      }];
+
+    systemd.services.panettone = {
+      wantedBy = [ "multi-user.target" ];
+      script = "${depot.web.panettone}/bin/panettone";
+
+      serviceConfig = {
+        DynamicUser = true;
+        Restart = "always";
+        EnvironmentFile = cfg.secretsFile;
+      };
+
+      environment = {
+        PANETTONE_PORT = toString cfg.port;
+        PGHOST = "localhost";
+        PGUSER = cfg.dbUser;
+        PGDATABASE = cfg.dbName;
+        IRCCATHOST = cfg.irccatHost;
+        IRCCATPORT = toString cfg.irccatPort;
+        ISSUECHANNEL = cfg.irccatChannel;
+      };
+    };
+
+    systemd.services.panettone-fixer = {
+      description = "Restart panettone regularly to work around b/225";
+      wantedBy = [ "multi-user.target" ];
+      script = "${pkgs.systemd}/bin/systemctl restart panettone";
+      serviceConfig.Type = "oneshot";
+
+      # We don't exactly know how frequently this occurs, but
+      # _probably_ not more than hourly.
+      startAt = "hourly";
+    };
+  };
+}
diff --git a/ops/modules/paroxysm.nix b/ops/modules/paroxysm.nix
new file mode 100644
index 000000000000..070e7623db09
--- /dev/null
+++ b/ops/modules/paroxysm.nix
@@ -0,0 +1,28 @@
+{ depot, config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.paroxysm;
+  description = "TVL's majestic IRC bot";
+in
+{
+  options.services.depot.paroxysm.enable = lib.mkEnableOption description;
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.paroxysm = {
+      inherit description;
+      script = "${depot.fun.paroxysm}/bin/paroxysm";
+      wantedBy = [ "multi-user.target" ];
+
+      environment = {
+        PARX_DATABASE_URL = "postgresql://tvldb:tvldb@localhost/tvldb";
+        PARX_IRC_CONFIG_PATH = "/var/lib/paroxysm/irc.toml";
+      };
+
+      serviceConfig = {
+        DynamicUser = true;
+        StateDirectory = "paroxysm";
+        Restart = "always";
+      };
+    };
+  };
+}
diff --git a/ops/modules/prometheus-fail2ban-exporter.nix b/ops/modules/prometheus-fail2ban-exporter.nix
new file mode 100644
index 000000000000..349364f9b7ed
--- /dev/null
+++ b/ops/modules/prometheus-fail2ban-exporter.nix
@@ -0,0 +1,52 @@
+{ config, lib, pkgs, depot, ... }:
+
+let
+  cfg = config.services.prometheus-fail2ban-exporter;
+in
+
+{
+  options.services.prometheus-fail2ban-exporter = with lib; {
+    enable = mkEnableOption "Prometheus Fail2ban Exporter";
+
+    interval = mkOption {
+      description = "Systemd calendar expression for how often to run the interval";
+      type = types.string;
+      default = "minutely";
+      example = "hourly";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services."prometheus-fail2ban-exporter" = {
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network.target" "fail2ban.service" ];
+      serviceConfig = {
+        User = "root";
+        Type = "oneshot";
+        ExecStart = pkgs.writeShellScript "prometheus-fail2ban-exporter" ''
+          set -eo pipefail
+          mkdir -p /var/lib/prometheus/node-exporter
+          exec prometheus-fail2ban-exporter
+        '';
+      };
+
+      path = [
+        pkgs.fail2ban
+        depot.third_party.prometheus-fail2ban-exporter
+      ];
+    };
+
+    systemd.timers."prometheus-fail2ban-exporter" = {
+      wantedBy = [ "multi-user.target" ];
+      timerConfig.OnCalendar = cfg.interval;
+    };
+
+    services.prometheus.exporters.node = {
+      enabledCollectors = [ "textfile" ];
+
+      extraFlags = [
+        "--collector.textfile.directory=/var/lib/prometheus/node-exporter"
+      ];
+    };
+  };
+}
diff --git a/ops/modules/quassel.nix b/ops/modules/quassel.nix
new file mode 100644
index 000000000000..6acb0615f4c0
--- /dev/null
+++ b/ops/modules/quassel.nix
@@ -0,0 +1,83 @@
+# A more modern module for running Quassel.
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.quassel;
+  quasselDaemon = pkgs.quassel.override {
+    monolithic = false;
+    enableDaemon = true;
+    withKDE = false;
+  };
+in
+{
+  options.services.depot.quassel = with lib; {
+    enable = mkEnableOption "Quassel IRC daemon";
+
+    acmeHost = mkOption {
+      description = "ACME host to use for the Quassel TLS certificate";
+      type = lib.types.str;
+    };
+
+    bindAddresses = mkOption {
+      description = "Addresses Quassel will bind to/listen on";
+      default = [ "127.0.0.1" ];
+    };
+
+    logLevel = mkOption {
+      description = "Log level for Quassel Core";
+      default = "Info";
+      type = lib.types.enum [
+        "Debug"
+        "Info"
+        "Warning"
+        "Error"
+      ];
+    };
+
+    port = mkOption {
+      default = 6698;
+      description = ''
+        The port number the Quassel daemon will be listening to.
+      '';
+    };
+  };
+
+  config = with lib; mkIf cfg.enable {
+    networking.firewall.allowedTCPPorts = [ cfg.port ];
+
+    systemd.services.quassel = {
+      description = "Quassel IRC daemon";
+      wantedBy = [ "multi-user.target" ];
+
+      script = concatStringsSep " " [
+        "${quasselDaemon}/bin/quasselcore"
+        "--listen=${concatStringsSep "," cfg.bindAddresses}"
+        "--port=${toString cfg.port}"
+        "--configdir=/var/lib/quassel"
+        "--require-ssl"
+        "--ssl-cert=$CREDENTIALS_DIRECTORY/quassel.pem"
+        "--loglevel=${cfg.logLevel}"
+      ];
+
+      serviceConfig = {
+        Restart = "always";
+        User = "quassel";
+        Group = "quassel";
+        StateDirectory = "quassel";
+
+        # Avoid trouble with the ACME file permissions by using the
+        # systemd credentials feature.
+        LoadCredential = "quassel.pem:/var/lib/acme/${cfg.acmeHost}/full.pem";
+      };
+    };
+
+    users = {
+      users.quassel = {
+        isSystemUser = true;
+        group = "quassel";
+      };
+
+      groups.quassel = { };
+    };
+  };
+}
diff --git a/ops/modules/restic.nix b/ops/modules/restic.nix
new file mode 100644
index 000000000000..869539603578
--- /dev/null
+++ b/ops/modules/restic.nix
@@ -0,0 +1,62 @@
+# Configure restic backups to S3-compatible storage, in our case
+# GleSYS object storage.
+#
+# Conventions:
+# - restic's cache lives in /var/backup/restic/cache
+# - repository password lives in /var/backup/restic/secret
+# - object storage credentials in /var/backup/restic/glesys-key
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.depot.restic;
+  description = "Restic backups to GleSYS";
+  mkStringOption = default: lib.mkOption {
+    inherit default;
+    type = lib.types.str;
+  };
+in
+{
+  options.services.depot.restic = {
+    enable = lib.mkEnableOption description;
+    bucketEndpoint = mkStringOption "objects.dc-sto1.glesys.net";
+    bucketName = mkStringOption "aged-resonance";
+    bucketCredentials = mkStringOption "/var/backup/restic/glesys-key";
+    repository = mkStringOption config.networking.hostName;
+    interval = mkStringOption "hourly";
+
+    paths = with lib; mkOption {
+      description = "Directories that should be backed up";
+      type = types.listOf types.str;
+    };
+
+    exclude = with lib; mkOption {
+      description = "Files that should be excluded from backups";
+      type = types.listOf types.str;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.restic = {
+      description = "Backups to GleSYS";
+
+      script = "${pkgs.restic}/bin/restic backup ${lib.concatStringsSep " " cfg.paths}";
+
+      environment = {
+        RESTIC_REPOSITORY = "s3:${cfg.bucketEndpoint}/${cfg.bucketName}/${cfg.repository}";
+        AWS_SHARED_CREDENTIALS_FILE = cfg.bucketCredentials;
+        RESTIC_PASSWORD_FILE = "/var/backup/restic/secret";
+        RESTIC_CACHE_DIR = "/var/backup/restic/cache";
+
+        RESTIC_EXCLUDE_FILE =
+          builtins.toFile "exclude-files" (lib.concatStringsSep "\n" cfg.exclude);
+      };
+    };
+
+    systemd.timers.restic = {
+      wantedBy = [ "multi-user.target" ];
+      timerConfig.OnCalendar = cfg.interval;
+    };
+
+    environment.systemPackages = [ pkgs.restic ];
+  };
+}
diff --git a/ops/modules/smtprelay.nix b/ops/modules/smtprelay.nix
new file mode 100644
index 000000000000..f6ce2621757d
--- /dev/null
+++ b/ops/modules/smtprelay.nix
@@ -0,0 +1,61 @@
+# NixOS module for configuring the simple SMTP relay.
+{ depot, pkgs, config, lib, ... }:
+
+let
+  inherit (builtins) attrValues mapAttrs;
+  inherit (lib)
+    concatStringsSep
+    mkEnableOption
+    mkIf
+    mkOption
+    types
+    ;
+
+  cfg = config.services.depot.smtprelay;
+  description = "Simple SMTP relay";
+
+  # Configuration values that are always overridden.
+  #
+  # - logging is pinned to stdout for journald compatibility
+  # - secret config is loaded through systemd's credential loading facility
+  overrideArgs = {
+    logfile = "";
+    config = "$CREDENTIALS_DIRECTORY/secrets";
+  };
+
+  # Creates the command line argument string for the service.
+  prepareArgs = args:
+    concatStringsSep " "
+      (attrValues (mapAttrs (key: value: "-${key} \"${toString value}\"")
+        (args // overrideArgs)));
+in
+{
+  options.services.depot.smtprelay = {
+    enable = mkEnableOption description;
+
+    args = mkOption {
+      type = types.attrsOf types.str;
+      description = "Key value pairs for command line arguments";
+    };
+
+    secretsFile = mkOption {
+      type = types.str;
+      default = config.age.secretsDir + "/smtprelay";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.smtprelay = {
+      inherit description;
+      script = "${depot.third_party.smtprelay}/bin/smtprelay ${prepareArgs cfg.args}";
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        Restart = "always";
+        StateDirectory = "smtprelay";
+        DynamicUser = true;
+        LoadCredential = "secrets:${cfg.secretsFile}";
+      };
+    };
+  };
+}
diff --git a/ops/modules/sourcegraph.nix b/ops/modules/sourcegraph.nix
new file mode 100644
index 000000000000..cbf836ab64d5
--- /dev/null
+++ b/ops/modules/sourcegraph.nix
@@ -0,0 +1,60 @@
+# Run sourcegraph, including its entire machinery, in a container.
+# Running it outside of a container is a futile endeavour for now.
+{ depot, config, pkgs, lib, ... }:
+
+let
+  cfg = config.services.depot.sourcegraph;
+in
+{
+  options.services.depot.sourcegraph = with lib; {
+    enable = mkEnableOption "SourceGraph code search engine";
+
+    port = mkOption {
+      description = "Port on which SourceGraph should listen";
+      type = types.int;
+      default = 3463;
+    };
+
+    cheddarPort = mkOption {
+      description = "Port on which cheddar should listen";
+      type = types.int;
+      default = 4238;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # Run a cheddar syntax highlighting server
+    systemd.services.cheddar-server = {
+      wantedBy = [ "multi-user.target" ];
+      script = "${depot.tools.cheddar}/bin/cheddar --listen 0.0.0.0:${toString cfg.cheddarPort} --sourcegraph-server";
+
+      serviceConfig = {
+        DynamicUser = true;
+        Restart = "always";
+      };
+    };
+
+    virtualisation.oci-containers.containers.sourcegraph = {
+      image = "sourcegraph/server:3.40.0";
+
+      ports = [
+        "127.0.0.1:${toString cfg.port}:7080"
+      ];
+
+      volumes = [
+        "/var/lib/sourcegraph/etc:/etc/sourcegraph"
+        "/var/lib/sourcegraph/data:/var/opt/sourcegraph"
+      ];
+
+      # TODO(tazjin): Figure out what changed in the protocol.
+      # environment.SRC_SYNTECT_SERVER = "http://172.17.0.1:${toString cfg.cheddarPort}";
+
+      # Sourcegraph needs a higher nofile limit, it logs warnings
+      # otherwise (unclear whether it actually affects the service).
+      extraOptions = [
+        "--ulimit"
+        "nofile=10000:10000"
+      ];
+    };
+  };
+}
diff --git a/ops/modules/tvl-buildkite.nix b/ops/modules/tvl-buildkite.nix
new file mode 100644
index 000000000000..3c6d88404fd0
--- /dev/null
+++ b/ops/modules/tvl-buildkite.nix
@@ -0,0 +1,80 @@
+# Configuration for the TVL buildkite agents.
+{ config, depot, pkgs, lib, ... }:
+
+let
+  cfg = config.services.depot.buildkite;
+  agents = lib.range 1 cfg.agentCount;
+  description = "Buildkite agents for TVL";
+
+  besadiiWithConfig = name: pkgs.writeShellScript "besadii-whitby" ''
+    export BESADII_CONFIG=/run/agenix/buildkite-besadii-config
+    exec -a ${name} ${depot.ops.besadii}/bin/besadii "$@"
+  '';
+
+  # All Buildkite hooks are actually besadii, but it's being invoked
+  # with different names.
+  buildkiteHooks = pkgs.runCommand "buildkite-hooks" { } ''
+    mkdir -p $out/bin
+    ln -s ${besadiiWithConfig "post-command"} $out/bin/post-command
+  '';
+
+  credentialHelper = pkgs.writeShellScriptBin "git-credential-gerrit-creds" ''
+    echo 'username=buildkite'
+    echo "password=$(jq -r '.gerritPassword' /run/agenix/buildkite-besadii-config)"
+  '';
+in
+{
+  options.services.depot.buildkite = {
+    enable = lib.mkEnableOption description;
+    agentCount = lib.mkOption {
+      type = lib.types.int;
+      description = "Number of Buildkite agents to launch";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # Run the Buildkite agents using the default upstream module.
+    services.buildkite-agents = builtins.listToAttrs (map
+      (n: rec {
+        name = "whitby-${toString n}";
+        value = {
+          inherit name;
+          enable = true;
+          tokenPath = config.age.secretsDir + "/buildkite-agent-token";
+          privateSshKeyPath = config.age.secretsDir + "/buildkite-private-key";
+          hooks.post-command = "${buildkiteHooks}/bin/post-command";
+          hooks.environment = ''
+            export PATH=$PATH:/run/wrappers/bin
+          '';
+
+          runtimePackages = with pkgs; [
+            bash
+            coreutils
+            credentialHelper
+            curl
+            git
+            gnutar
+            gzip
+            jq
+            nix
+          ];
+        };
+      })
+      agents);
+
+    # Set up a group for all Buildkite agent users
+    users = {
+      groups.buildkite-agents = { };
+      users = builtins.listToAttrs (map
+        (n: rec {
+          name = "buildkite-agent-whitby-${toString n}";
+          value = {
+            isSystemUser = true;
+            group = lib.mkForce "buildkite-agents";
+            extraGroups = [ name "docker" ];
+          };
+        })
+        agents);
+    };
+  };
+}
diff --git a/ops/modules/tvl-cache.nix b/ops/modules/tvl-cache.nix
new file mode 100644
index 000000000000..683818d103b7
--- /dev/null
+++ b/ops/modules/tvl-cache.nix
@@ -0,0 +1,19 @@
+{ config, lib, pkgs, ... }:
+
+{
+  options = {
+    tvl.cache.enable = lib.mkEnableOption "the TVL binary cache";
+  };
+
+  config = lib.mkIf config.tvl.cache.enable {
+    nix.settings = {
+      trusted-public-keys = [
+        "cache.tvl.su:kjc6KOMupXc1vHVufJUoDUYeLzbwSr9abcAKdn/U1Jk="
+      ];
+
+      substituters = [
+        "https://cache.tvl.su"
+      ];
+    };
+  };
+}
diff --git a/ops/modules/tvl-headscale.nix b/ops/modules/tvl-headscale.nix
new file mode 100644
index 000000000000..a07021c78861
--- /dev/null
+++ b/ops/modules/tvl-headscale.nix
@@ -0,0 +1,62 @@
+# Configuration for the coordination server for net.tvl.fyi, a
+# tailscale network run using headscale.
+#
+# All TVL members can join this network, which provides several exit
+# nodes through which traffic can be routed.
+#
+# The coordination server is currently run on sanduny.tvl.su. It is
+# managed manually, ping somebody with access ... for access.
+#
+# Servers should join using approximately this command:
+#   tailscale up --login-server https://net.tvl.fyi --accept-dns=false --advertise-exit-node
+#
+# Clients should join using approximately this command:
+#   tailscale up --login-server https://net.tvl.fyi --accept-dns=false
+{ config, pkgs, ... }:
+
+{
+  # TODO(tazjin): run embedded DERP server
+  services.headscale = {
+    enable = true;
+    port = 4725; # hscl
+
+    settings = {
+      server_url = "https://net.tvl.fyi";
+      dns_config.nameservers = [
+        "8.8.8.8"
+        "1.1.1.1"
+        "77.88.8.8"
+      ];
+
+      # TLS is handled by nginx
+      tls_cert_path = null;
+      tls_key_path = null;
+    };
+  };
+
+  environment.systemPackages = [ pkgs.headscale ]; # admin CLI
+
+  services.nginx.virtualHosts."net.tvl.fyi" = {
+    serverName = "net.tvl.fyi";
+    enableACME = true;
+    forceSSL = true;
+
+    # See https://github.com/juanfont/headscale/blob/v0.22.3/docs/reverse-proxy.md#nginx
+    extraConfig = ''
+      location / {
+        proxy_pass http://localhost:${toString config.services.headscale.port};
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+        proxy_set_header Host $server_name;
+        proxy_redirect http:// https://;
+        proxy_buffering off;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+        add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
+      }
+    '';
+  };
+
+}
diff --git a/ops/modules/tvl-slapd/default.nix b/ops/modules/tvl-slapd/default.nix
new file mode 100644
index 000000000000..c08cd30f166c
--- /dev/null
+++ b/ops/modules/tvl-slapd/default.nix
@@ -0,0 +1,81 @@
+# Configures an OpenLDAP instance for TVL
+#
+# TODO(tazjin): Configure ldaps://
+{ depot, lib, pkgs, ... }:
+
+with depot.nix.yants;
+
+let
+  user = struct {
+    username = string;
+    email = string;
+    password = string;
+    displayName = option string;
+  };
+
+  toLdif = defun [ user string ] (u: ''
+    dn: cn=${u.username},ou=users,dc=tvl,dc=fyi
+    objectClass: organizationalPerson
+    objectClass: inetOrgPerson
+    sn: ${u.username}
+    cn: ${u.username}
+    displayName: ${u.displayName or u.username}
+    mail: ${u.email}
+    userPassword: ${u.password}
+  '');
+
+  inherit (depot.ops) users;
+
+in
+{
+  services.openldap = {
+    enable = true;
+
+    settings.children = {
+      "olcDatabase={1}mdb".attrs = {
+        objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ];
+        olcDatabase = "{1}mdb";
+        olcDbDirectory = "/var/lib/openldap/db";
+        olcSuffix = "dc=tvl,dc=fyi";
+        olcAccess = "to *  by * read";
+        olcRootDN = "cn=admin,dc=tvl,dc=fyi";
+        olcRootPW = "{ARGON2}$argon2id$v=19$m=65536,t=2,p=1$OfcgkOQ96VQ3aJj7NfA9vQ$oS6HQOkYl/bUYg4SejpltQYy7kvqx/RUxvoR4zo1vXU";
+      };
+
+      "cn=module{0}".attrs = {
+        objectClass = "olcModuleList";
+        olcModuleLoad = "argon2";
+      };
+
+      "cn=schema".includes =
+        map (schema: "${pkgs.openldap}/etc/schema/${schema}.ldif")
+          [ "core" "cosine" "inetorgperson" "nis" ];
+    };
+
+    # Contents are immutable at runtime, and adding user accounts etc.
+    # is done statically in the LDIF-formatted contents in this folder.
+    declarativeContents."dc=tvl,dc=fyi" = ''
+      dn: dc=tvl,dc=fyi
+      dc: tvl
+      o: TVL LDAP server
+      description: Root entry for tvl.fyi
+      objectClass: top
+      objectClass: dcObject
+      objectClass: organization
+
+      dn: ou=users,dc=tvl,dc=fyi
+      ou: users
+      description: All users in TVL
+      objectClass: top
+      objectClass: organizationalUnit
+
+      dn: ou=groups,dc=tvl,dc=fyi
+      ou: groups
+      description: All groups in TVL
+      objectClass: top
+      objectClass: organizationalUnit
+
+      ${lib.concatStringsSep "\n" (map toLdif users)}
+    '';
+  };
+}
diff --git a/ops/modules/tvl-users.nix b/ops/modules/tvl-users.nix
new file mode 100644
index 000000000000..d8269746a8f1
--- /dev/null
+++ b/ops/modules/tvl-users.nix
@@ -0,0 +1,85 @@
+# Standard NixOS users for TVL machines, as well as configuration that
+# should following along when they are added to a machine.
+{ depot, pkgs, ... }:
+
+{
+  users = {
+    users.tazjin = {
+      isNormalUser = true;
+      extraGroups = [ "git" "wheel" ];
+      shell = pkgs.fish;
+      openssh.authorizedKeys.keys = depot.users.tazjin.keys.all;
+    };
+
+    users.lukegb = {
+      isNormalUser = true;
+      extraGroups = [ "git" "wheel" ];
+      openssh.authorizedKeys.keys = depot.users.lukegb.keys.all;
+    };
+
+    users.grfn = {
+      isNormalUser = true;
+      extraGroups = [ "git" "wheel" ];
+      openssh.authorizedKeys.keys = [
+        depot.users.grfn.keys.whitby
+      ];
+    };
+
+    users.edef = {
+      isNormalUser = true;
+      extraGroups = [ "git" ];
+      openssh.authorizedKeys.keys = depot.users.edef.keys.all;
+    };
+
+    users.qyliss = {
+      isNormalUser = true;
+      description = "Alyssa Ross";
+      extraGroups = [ "git" ];
+      openssh.authorizedKeys.keys = depot.users.qyliss.keys.all;
+    };
+
+    users.eta = {
+      isNormalUser = true;
+      extraGroups = [ "git" ];
+      openssh.authorizedKeys.keys = depot.users.eta.keys.whitby;
+    };
+
+    users.cynthia = {
+      isNormalUser = true; # I'm normal OwO :3
+      extraGroups = [ "git" ];
+      openssh.authorizedKeys.keys = depot.users.cynthia.keys.all;
+    };
+
+    users.firefly = {
+      isNormalUser = true;
+      extraGroups = [ "git" ];
+      openssh.authorizedKeys.keys = depot.users.firefly.keys.whitby;
+    };
+
+    users.sterni = {
+      isNormalUser = true;
+      extraGroups = [ "git" "wheel" ];
+      openssh.authorizedKeys.keys = depot.users.sterni.keys.all;
+    };
+
+    users.flokli = {
+      isNormalUser = true;
+      extraGroups = [ "git" "wheel" ];
+      openssh.authorizedKeys.keys = depot.users.flokli.keys.all;
+    };
+  };
+
+  programs.fish.enable = true;
+
+  environment.systemPackages = with pkgs; [
+    alacritty.terminfo
+    foot.terminfo
+    rxvt-unicode-unwrapped.terminfo
+    kitty.terminfo
+  ];
+
+  security.sudo.extraRules = [{
+    groups = [ "wheel" ];
+    commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
+  }];
+}
diff --git a/ops/modules/v4l2loopback.nix b/ops/modules/v4l2loopback.nix
new file mode 100644
index 000000000000..636b2ff6cf27
--- /dev/null
+++ b/ops/modules/v4l2loopback.nix
@@ -0,0 +1,12 @@
+{ config, lib, pkgs, ... }:
+
+{
+  boot = {
+    extraModulePackages = [ config.boot.kernelPackages.v4l2loopback ];
+    kernelModules = [ "v4l2loopback" ];
+    extraModprobeConfig = ''
+      options v4l2loopback exclusive_caps=1
+    '';
+  };
+}
+
diff --git a/ops/modules/www/atward.tvl.fyi.nix b/ops/modules/www/atward.tvl.fyi.nix
new file mode 100644
index 000000000000..6b3672dd75cb
--- /dev/null
+++ b/ops/modules/www/atward.tvl.fyi.nix
@@ -0,0 +1,33 @@
+# Serve atward, the query redirection ... thing.
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    # Short link support (i.e. plain http://at) for users with a
+    # configured tvl.fyi/tvl.su search domain.
+    services.nginx.virtualHosts."at-shortlink" = {
+      serverName = "at";
+      extraConfig = "return 302 https://atward.tvl.fyi$request_uri;";
+    };
+
+    services.nginx.virtualHosts."atward" = {
+      serverName = "atward.tvl.fyi";
+      enableACME = true;
+      forceSSL = true;
+
+      serverAliases = [
+        "atward.tvl.su"
+        "at.tvl.fyi"
+        "at.tvl.su"
+      ];
+
+      locations."/" = {
+        proxyPass = "http://localhost:${toString config.services.depot.atward.port}";
+      };
+    };
+  };
+}
diff --git a/ops/modules/www/auth.tvl.fyi.nix b/ops/modules/www/auth.tvl.fyi.nix
new file mode 100644
index 000000000000..a068f023658e
--- /dev/null
+++ b/ops/modules/www/auth.tvl.fyi.nix
@@ -0,0 +1,28 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."auth.tvl.fyi" = {
+      serverName = "auth.tvl.fyi";
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        # increase buffer size for large headers
+        proxy_buffers 8 16k;
+        proxy_buffer_size 16k;
+
+        location / {
+          proxy_pass http://localhost:${toString config.services.keycloak.settings.http-port};
+          proxy_set_header X-Forwarded-For $remote_addr;
+          proxy_set_header X-Forwarded-Proto https;
+          proxy_set_header Host $host;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/b.tvl.fyi.nix b/ops/modules/www/b.tvl.fyi.nix
new file mode 100644
index 000000000000..45f6c6ed5141
--- /dev/null
+++ b/ops/modules/www/b.tvl.fyi.nix
@@ -0,0 +1,32 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."b-shortlink" = {
+      serverName = "b";
+      extraConfig = "return 302 https://b.tvl.fyi$request_uri;";
+    };
+
+    services.nginx.virtualHosts."b.tvl.fyi" = {
+      serverName = "b.tvl.fyi";
+      serverAliases = [ "b.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        # Forward short links to issues to the issue itself (b/32)
+        location ~ ^/(\d+)$ {
+          return 302 https://b.tvl.fyi/issues$request_uri;
+        }
+
+        location / {
+          proxy_pass http://localhost:${toString config.services.depot.panettone.port};
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/base.nix b/ops/modules/www/base.nix
new file mode 100644
index 000000000000..50fceff0fa40
--- /dev/null
+++ b/ops/modules/www/base.nix
@@ -0,0 +1,41 @@
+{ config, pkgs, ... }:
+
+{
+  config = {
+    security.acme = {
+      acceptTerms = true;
+      defaults.email = "letsencrypt@tvl.su";
+    };
+
+    services.nginx = {
+      enable = true;
+      enableReload = true;
+
+      recommendedTlsSettings = true;
+      recommendedGzipSettings = true;
+      recommendedProxySettings = true;
+
+      commonHttpConfig = ''
+        log_format json_combined escape=json
+        '{'
+            '"remote_addr":"$remote_addr",'
+            '"method":"$request_method",'
+            '"host":"$host",'
+            '"uri":"$request_uri",'
+            '"status":$status,'
+            '"request_size":$request_length,'
+            '"response_size":$body_bytes_sent,'
+            '"response_time":$request_time,'
+            '"referrer":"$http_referer",'
+            '"user_agent":"$http_user_agent"'
+        '}';
+
+        access_log syslog:server=unix:/dev/log,nohostname json_combined;
+      '';
+
+      appendHttpConfig = ''
+        add_header Permissions-Policy "interest-cohort=()";
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/cache.tvl.su.nix b/ops/modules/www/cache.tvl.su.nix
new file mode 100644
index 000000000000..99bc008cd6a5
--- /dev/null
+++ b/ops/modules/www/cache.tvl.su.nix
@@ -0,0 +1,31 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."cache.tvl.su" = {
+      serverName = "cache.tvl.su";
+      serverAliases = [ "cache.tvl.fyi" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location = /cache-key.pub {
+          alias /run/agenix/nix-cache-pub;
+        }
+
+        location = /nix-cache-info {
+          add_header Content-Type text/plain;
+          return 200 "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 50\n";
+        }
+
+        location / {
+          proxy_pass http://localhost:${toString config.services.nix-serve.port};
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/cl.tvl.fyi.nix b/ops/modules/www/cl.tvl.fyi.nix
new file mode 100644
index 000000000000..36422a6c4e38
--- /dev/null
+++ b/ops/modules/www/cl.tvl.fyi.nix
@@ -0,0 +1,34 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."cl-shortlink" = {
+      serverName = "cl";
+      extraConfig = "return 302 https://cl.tvl.fyi$request_uri;";
+    };
+
+    services.nginx.virtualHosts.gerrit = {
+      serverName = "cl.tvl.fyi";
+      serverAliases = [ "cl.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location / {
+          proxy_pass http://localhost:4778;
+          proxy_set_header  X-Forwarded-For $remote_addr;
+          # The :443 suffix is a workaround for https://b.tvl.fyi/issues/88.
+          proxy_set_header  Host $host:443;
+        }
+
+        location = /robots.txt {
+          return 200 'User-agent: *\nAllow: /';
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/code.tvl.fyi.nix b/ops/modules/www/code.tvl.fyi.nix
new file mode 100644
index 000000000000..bef7f3f94d70
--- /dev/null
+++ b/ops/modules/www/code.tvl.fyi.nix
@@ -0,0 +1,65 @@
+{ depot, pkgs, config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts.cgit = {
+      serverName = "code.tvl.fyi";
+      serverAliases = [ "code.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        # Serve the rendered Tvix component SVG.
+        #
+        # TODO(tazjin): Implement a way of serving this dynamically
+        location = /about/tvix/docs/component-flow.svg {
+            alias ${depot.tvix.docs.svg}/component-flow.svg;
+        }
+
+        location = /go-get/tvix/castore/protos {
+            alias ${pkgs.writeText "go-import-metadata.html" ''<html><meta name="go-import" content="code.tvl.fyi/tvix/castore/protos git https://code.tvl.fyi/depot.git:/tvix/castore/protos.git"></html>''};
+        }
+
+        location = /go-get/tvix/store/protos {
+            alias ${pkgs.writeText "go-import-metadata.html" ''<html><meta name="go-import" content="code.tvl.fyi/tvix/store/protos git https://code.tvl.fyi/depot.git:/tvix/store/protos.git"></html>''};
+        }
+
+        location = /tvix/castore/protos {
+            if ($args ~* "/?go-get=1") {
+                return 302 /go-get/tvix/castore/protos;
+            }
+        }
+
+        location = /tvix/store/protos {
+            if ($args ~* "/?go-get=1") {
+                return 302 /go-get/tvix/store/protos;
+            }
+        }
+
+        # Git operations on depot.git hit josh
+        location /depot.git {
+            proxy_pass http://localhost:${toString config.services.depot.josh.port};
+        }
+
+        # Git clone operations on '/' should be redirected to josh now.
+        location = /info/refs {
+            return 302 https://code.tvl.fyi/depot.git/info/refs$is_args$args;
+        }
+
+        # Static assets must always hit the root.
+        location ~ ^/(favicon\.ico|cgit\.(css|png))$ {
+           proxy_pass http://localhost:2448;
+        }
+
+        # Everything else is forwarded to cgit for the web view
+        location / {
+            proxy_pass http://localhost:2448/cgit.cgi/depot/;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/cs.tvl.fyi.nix b/ops/modules/www/cs.tvl.fyi.nix
new file mode 100644
index 000000000000..fac814baf064
--- /dev/null
+++ b/ops/modules/www/cs.tvl.fyi.nix
@@ -0,0 +1,31 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."cs.tvl.fyi" = {
+      serverName = "cs.tvl.fyi";
+      serverAliases = [ "cs.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location = / {
+          return 301 https://cs.tvl.fyi/depot;
+        }
+
+        location / {
+          proxy_set_header X-Sg-Auth "Anonymous";
+          proxy_pass http://localhost:${toString config.services.depot.sourcegraph.port};
+        }
+
+        location /users/Anonymous/settings {
+          return 301 https://cs.tvl.fyi;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/deploys.tvl.fyi.nix b/ops/modules/www/deploys.tvl.fyi.nix
new file mode 100644
index 000000000000..ffbe225b58a3
--- /dev/null
+++ b/ops/modules/www/deploys.tvl.fyi.nix
@@ -0,0 +1,22 @@
+{ pkgs, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    # Ensure the directory for deployment diffs exists.
+    systemd.tmpfiles.rules = [
+      "d /var/html/deploys.tvl.fyi/diff 0755 nginx nginx -"
+    ];
+
+    services.nginx.virtualHosts."deploys.tvl.fyi" = {
+      enableACME = true;
+      forceSSL = true;
+      root = "/var/html/deploys.tvl.fyi";
+    };
+
+    services.depot.restic.paths = [ "/var/html/deploys.tvl.fyi" ];
+  };
+}
diff --git a/ops/modules/www/grep.tvl.fyi.nix b/ops/modules/www/grep.tvl.fyi.nix
new file mode 100644
index 000000000000..8f16e8ccbdba
--- /dev/null
+++ b/ops/modules/www/grep.tvl.fyi.nix
@@ -0,0 +1,22 @@
+# Experimental configuration for manually Livegrep.
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    # Short link support (i.e. plain http://at) for users with a
+    # configured tvl.fyi/tvl.su search domain.
+    services.nginx.virtualHosts."grep.tvl.fyi" = {
+      enableACME = true;
+      forceSSL = true;
+
+      locations."/" = {
+        # experimental: manually run Docker container
+        proxyPass = "http://172.17.0.3:8910";
+      };
+    };
+  };
+}
diff --git a/ops/modules/www/inbox.tvl.su.nix b/ops/modules/www/inbox.tvl.su.nix
new file mode 100644
index 000000000000..38db5d2a8eda
--- /dev/null
+++ b/ops/modules/www/inbox.tvl.su.nix
@@ -0,0 +1,31 @@
+{ config, depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."inbox.tvl.su" = {
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        # nginx is incapable of serving a single file at /, hence this hack:
+        location = / {
+          index /landing-page;
+        }
+
+        location = /landing-page {
+          types { } default_type "text/html; charset=utf-8";
+          alias ${depot.web.inbox};
+        }
+
+        # rest of requests is proxied to public-inbox-httpd
+        location / {
+          proxy_pass http://localhost:${toString config.services.public-inbox.http.port};
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/nixery.dev.nix b/ops/modules/www/nixery.dev.nix
new file mode 100644
index 000000000000..05dc88c66a07
--- /dev/null
+++ b/ops/modules/www/nixery.dev.nix
@@ -0,0 +1,21 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."nixery.dev" = {
+      serverName = "nixery.dev";
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location / {
+          proxy_pass http://localhost:${toString config.services.depot.nixery.port};
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/self-redirect.nix b/ops/modules/www/self-redirect.nix
new file mode 100644
index 000000000000..5bf1627be99a
--- /dev/null
+++ b/ops/modules/www/self-redirect.nix
@@ -0,0 +1,27 @@
+# Redirect the hostname of a machine to its configuration in a web
+# browser.
+#
+# Works by convention, assuming that the machine has its configuration
+# at //ops/machines/${hostname}.
+{ config, ... }:
+
+let
+  host = "${config.networking.hostName}.${config.networking.domain}";
+in
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config.services.nginx.virtualHosts."${host}" = {
+    serverName = host;
+    addSSL = true; # SSL is not forced on these redirects
+    enableACME = true;
+
+    extraConfig = ''
+      location = / {
+        return 302 https://at.tvl.fyi/?q=%2F%2Fops%2Fmachines%2F${config.networking.hostName};
+      }
+    '';
+  };
+}
diff --git a/ops/modules/www/signup.tvl.fyi.nix b/ops/modules/www/signup.tvl.fyi.nix
new file mode 100644
index 000000000000..1b193f99a9ed
--- /dev/null
+++ b/ops/modules/www/signup.tvl.fyi.nix
@@ -0,0 +1,19 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."signup.tvl.fyi" = {
+      root = depot.web.pwcrypt;
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/static.tvl.fyi.nix b/ops/modules/www/static.tvl.fyi.nix
new file mode 100644
index 000000000000..7312f78ecf42
--- /dev/null
+++ b/ops/modules/www/static.tvl.fyi.nix
@@ -0,0 +1,42 @@
+# Host the static assets at static.tvl.fyi
+#
+# All assets are served from $base/$drvhash/$file, but can also be
+# included with `latest/` which will return a (non-permanent!)
+# redirect to the real location.
+#
+# For all purposes within depot, using the drvhash of web.static is
+# recommended.
+{ depot, pkgs, ... }:
+
+let staticHash = depot.web.static.drvHash;
+in {
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."static.tvl.fyi" = {
+      serverAliases = [ "static.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location = / {
+          add_header Content-Type text/plain;
+          return 200 "looking for tvl.fyi or tvl.su?";
+        }
+
+        location /latest {
+          rewrite ^/latest/(.*) /${staticHash}/$1 redirect;
+        }
+
+        location /${staticHash}/ {
+          alias ${depot.web.static}/;
+          expires max;
+          add_header Access-Control-Allow-Origin "*";
+          add_header Cache-Control "public";
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/status.tvl.su.nix b/ops/modules/www/status.tvl.su.nix
new file mode 100644
index 000000000000..7079c602604e
--- /dev/null
+++ b/ops/modules/www/status.tvl.su.nix
@@ -0,0 +1,25 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."status-fyi" = {
+      serverName = "status.tvl.fyi";
+      enableACME = true;
+      extraConfig = "return 302 https://status.tvl.su$request_uri;";
+    };
+
+    services.nginx.virtualHosts.grafana = {
+      serverName = "status.tvl.su";
+      enableACME = true;
+      forceSSL = true;
+
+      locations."/" = {
+        proxyPass = "http://localhost:${toString config.services.grafana.settings.server.http_port}";
+      };
+    };
+  };
+}
diff --git a/ops/modules/www/tazj.in.nix b/ops/modules/www/tazj.in.nix
new file mode 100644
index 000000000000..3b80222e0d66
--- /dev/null
+++ b/ops/modules/www/tazj.in.nix
@@ -0,0 +1,49 @@
+# serve tazjin's website & blog
+{ depot, config, lib, pkgs, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."tazj.in" = {
+      enableACME = true;
+      forceSSL = true;
+      root = depot.users.tazjin.homepage;
+      serverAliases = [ "www.tazj.in" ];
+
+      extraConfig = ''
+        location = /en/rss.xml {
+          return 301 https://tazj.in/feed.atom;
+        }
+
+        ${depot.users.tazjin.blog.oldRedirects}
+        location /blog/ {
+          alias ${depot.users.tazjin.blog.rendered}/;
+
+          if ($request_uri ~ ^/(.*)\.html$) {
+            return 302 /$1;
+          }
+
+          try_files $uri $uri.html $uri/ =404;
+        }
+
+        location = /predlozhnik {
+          return 302 https://predlozhnik.ru;
+        }
+
+        # Temporary place for serving static files.
+        location /blobs/ {
+          alias /var/lib/tazjins-blobs/;
+        }
+      '';
+    };
+
+    services.nginx.virtualHosts."git.tazj.in" = {
+      enableACME = true;
+      forceSSL = true;
+      extraConfig = "return 301 https://code.tvl.fyi$request_uri;";
+    };
+  };
+}
diff --git a/ops/modules/www/todo.tvl.fyi.nix b/ops/modules/www/todo.tvl.fyi.nix
new file mode 100644
index 000000000000..b53f5437e7ab
--- /dev/null
+++ b/ops/modules/www/todo.tvl.fyi.nix
@@ -0,0 +1,25 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."todo.tvl.fyi" = {
+      serverName = "todo.tvl.fyi";
+      serverAliases = [ "todo.tvl.su" ];
+      root = depot.web.todolist;
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+        location ~* \.(webp|woff2)$ {
+          add_header Cache-Control "public, max-age=31536000";
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/tvix.dev.nix b/ops/modules/www/tvix.dev.nix
new file mode 100644
index 000000000000..f86f5b3b1e98
--- /dev/null
+++ b/ops/modules/www/tvix.dev.nix
@@ -0,0 +1,39 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."tvix.dev" = {
+      serverName = "tvix.dev";
+      enableACME = true;
+      forceSSL = true;
+      root = depot.tvix.website;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+      '';
+    };
+
+    services.nginx.virtualHosts."docs.tvix.dev" = {
+      serverName = "docs.tvix.dev";
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+        location = / {
+          # until we have a better default page here
+          return 301 https://docs.tvix.dev/rust/tvix_eval/index.html;
+        }
+
+        location /rust/ {
+          alias ${depot.tvix.rust-docs}/;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/tvixbolt.tvl.su.nix b/ops/modules/www/tvixbolt.tvl.su.nix
new file mode 100644
index 000000000000..7adddd123632
--- /dev/null
+++ b/ops/modules/www/tvixbolt.tvl.su.nix
@@ -0,0 +1,19 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."tvixbolt.tvl.su" = {
+      root = depot.corp.tvixbolt;
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/tvl.fyi.nix b/ops/modules/www/tvl.fyi.nix
new file mode 100644
index 000000000000..59ee1bc27f1a
--- /dev/null
+++ b/ops/modules/www/tvl.fyi.nix
@@ -0,0 +1,47 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."tvl.fyi" = {
+      serverName = "tvl.fyi";
+      root = depot.web.tvl;
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
+        rewrite ^/builds/?$ https://buildkite.com/tvl/depot/ last;
+
+        rewrite ^/monorepo-doc/?$ https://docs.google.com/document/d/1nnyByXcH0F6GOmEezNOUa2RFelpeRpDToBLYD_CtjWE/edit?usp=sharing last;
+
+        rewrite ^/irc/?$ ircs://irc.hackint.org:6697/#tvl last;
+        rewrite ^/webchat/?$ https://webirc.hackint.org/#ircs://irc.hackint.org/#tvl last;
+
+        location ~* \.(webp|woff2)$ {
+          add_header Cache-Control "public, max-age=31536000";
+        }
+
+        location /blog {
+          if ($request_uri ~ ^/(.*)\.html$) {
+            return 302 /$1;
+          }
+
+          try_files $uri $uri.html $uri/ =404;
+        }
+
+        location = /blog {
+          return 302 /#blog;
+        }
+
+        location = /blog/ {
+          return 302 /#blog;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/tvl.su.nix b/ops/modules/www/tvl.su.nix
new file mode 100644
index 000000000000..a7c4f6a21721
--- /dev/null
+++ b/ops/modules/www/tvl.su.nix
@@ -0,0 +1,20 @@
+{ depot, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."tvl.su" = {
+      serverName = "tvl.su";
+      root = depot.corp.website;
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/wigglydonke.rs.nix b/ops/modules/www/wigglydonke.rs.nix
new file mode 100644
index 000000000000..3d85e4eb9843
--- /dev/null
+++ b/ops/modules/www/wigglydonke.rs.nix
@@ -0,0 +1,15 @@
+{ depot, lib, pkgs, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."wigglydonke.rs" = {
+      enableACME = true;
+      forceSSL = true;
+      root = "${depot.path + "/users/grfn/wigglydonke.rs"}";
+    };
+  };
+}
diff --git a/ops/modules/yandex-cloud.nix b/ops/modules/yandex-cloud.nix
new file mode 100644
index 000000000000..cf6d1eb81067
--- /dev/null
+++ b/ops/modules/yandex-cloud.nix
@@ -0,0 +1,78 @@
+# Profile for virtual machines on Yandex Cloud, intended for disk
+# images.
+#
+# https://cloud.yandex.com/en/docs/compute/operations/image-create/custom-image
+#
+# TODO(tazjin): Upstream to nixpkgs once it works well.
+{ config, lib, pkgs, modulesPath, ... }:
+
+let
+  cfg = config.virtualisation.yandexCloud;
+
+  # Kernel modules required for interacting with the hypervisor. These
+  # must be available during stage 1 boot and during normal operation,
+  # as disks and network do not work without them.
+  modules = [
+    "virtio-net"
+    "virtio-blk"
+    "virtio-pci"
+    "virtiofs"
+  ];
+in
+{
+  imports = [
+    "${modulesPath}/profiles/headless.nix"
+  ];
+
+  options = {
+    virtualisation.yandexCloud.rootPartitionUuid = with lib; mkOption {
+      type = types.str;
+      default = "C55A5EE2-E5FA-485C-B3AE-CC928429AB6B";
+
+      description = ''
+        UUID to use for the root partition of the disk image. Yandex
+        Cloud requires that root partitions are mounted by UUID.
+
+        Most users do not need to set this to a non-default value.
+      '';
+    };
+  };
+
+  config = {
+    fileSystems."/" = {
+      device = "/dev/disk/by-uuid/${lib.toLower cfg.rootPartitionUuid}";
+      fsType = "ext4";
+      autoResize = true;
+    };
+
+    boot = {
+      loader.grub.device = "/dev/vda";
+
+      initrd.kernelModules = modules;
+      kernelModules = modules;
+      kernelParams = [
+        # Enable support for the serial console
+        "console=ttyS0"
+      ];
+
+      growPartition = true;
+    };
+
+    environment.etc.securetty = {
+      text = "ttyS0";
+      mode = "0644";
+    };
+
+    systemd.services."serial-getty@ttyS0".enable = true;
+
+    services.openssh.enable = true;
+
+    system.build.yandexCloudImage = import (pkgs.path + "/nixos/lib/make-disk-image.nix") {
+      inherit lib config pkgs;
+      additionalSpace = "128M";
+      format = "qcow2";
+      partitionTableType = "legacy+gpt";
+      rootGPUID = cfg.rootPartitionUuid;
+    };
+  };
+}