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/default.nix92
-rw-r--r--ops/modules/cgit/thttpd_cgi_idx.patch13
-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/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.nix152
-rw-r--r--ops/modules/nixery.nix43
-rw-r--r--ops/modules/oauth2_proxy.nix53
-rw-r--r--ops/modules/owothia.nix68
-rw-r--r--ops/modules/panettone.nix108
-rw-r--r--ops/modules/paroxysm.nix28
-rw-r--r--ops/modules/prometheus-fail2ban-exporter.nix52
-rw-r--r--ops/modules/quassel.nix79
-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.nix76
-rw-r--r--ops/modules/tvl-cache.nix19
-rw-r--r--ops/modules/tvl-slapd/default.nix81
-rw-r--r--ops/modules/tvl-users.nix94
-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.nix24
-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.nix30
-rw-r--r--ops/modules/www/code.tvl.fyi.nix45
-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/images.tvl.fyi.nix22
-rw-r--r--ops/modules/www/nixery.dev.nix21
-rw-r--r--ops/modules/www/self-redirect.nix27
-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.nix40
-rw-r--r--ops/modules/www/todo.tvl.fyi.nix25
-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
50 files changed, 2260 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/default.nix b/ops/modules/cgit/default.nix
new file mode 100644
index 000000000000..580b8384bd90
--- /dev/null
+++ b/ops/modules/cgit/default.nix
@@ -0,0 +1,92 @@
+# Configuration for running the TVL cgit instance using thttpd.
+{ config, depot, lib, pkgs, ... }:
+
+let
+  inherit (pkgs) writeText;
+
+  cfg = config.services.depot.cgit;
+
+  cgitConfig = writeText "cgitrc" ''
+    # Global configuration
+    virtual-root=/
+    enable-http-clone=0
+    readme=:README.md
+    about-filter=${depot.tools.cheddar.about-filter}/bin/cheddar-about
+    source-filter=${depot.tools.cheddar}/bin/cheddar
+    enable-log-filecount=1
+    enable-log-linecount=1
+    enable-follow-links=1
+    enable-blame=1
+    mimetype-file=${pkgs.mime-types}/etc/mime.types
+    logo=https://static.tvl.fyi/${depot.web.static.drvHash}/logo-animated.svg
+
+    # Repository configuration
+    repo.url=depot
+    repo.path=/var/lib/gerrit/git/depot.git/
+    repo.desc=monorepo for the virus lounge
+    repo.owner=The Virus Lounge
+    repo.clone-url=https://code.tvl.fyi/depot.git
+  '';
+
+  thttpdConfig = writeText "thttpd.conf" ''
+    port=${toString cfg.port}
+    dir=${depot.third_party.cgit}/cgit
+    nochroot
+    novhost
+    cgipat=**.cgi
+  '';
+
+  # Patched version of thttpd that serves cgit.cgi as the index and
+  # sets the environment variable for pointing cgit at the correct
+  # configuration.
+  #
+  # Things are done this way because recompilation of thttpd is much
+  # faster than cgit.
+  thttpdConfigPatch = writeText "thttpd_cgit_conf.patch" ''
+    diff --git a/libhttpd.c b/libhttpd.c
+    index c6b1622..eef4b73 100644
+    --- a/libhttpd.c
+    +++ b/libhttpd.c
+    @@ -3055,4 +3055,6 @@ make_envp( httpd_conn* hc )
+
+         envn = 0;
+    +    // force cgit to load the correct configuration
+    +    envp[envn++] = "CGIT_CONFIG=${cgitConfig}";
+         envp[envn++] = build_env( "PATH=%s", CGI_PATH );
+     #ifdef CGI_LD_LIBRARY_PATH
+  '';
+
+  thttpdCgit = pkgs.thttpd.overrideAttrs (old: {
+    patches = [
+      ./thttpd_cgi_idx.patch
+      thttpdConfigPatch
+    ];
+  });
+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;
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.cgit = {
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        Restart = "on-failure";
+        User = "git";
+        Group = "git";
+
+        ExecStart = pkgs.writeShellScript "cgit-launch" ''
+          exec ${thttpdCgit}/bin/thttpd -D -C ${thttpdConfig}
+        '';
+      };
+    };
+  };
+}
diff --git a/ops/modules/cgit/thttpd_cgi_idx.patch b/ops/modules/cgit/thttpd_cgi_idx.patch
new file mode 100644
index 000000000000..67dbc0c7ab80
--- /dev/null
+++ b/ops/modules/cgit/thttpd_cgi_idx.patch
@@ -0,0 +1,13 @@
+diff --git a/config.h b/config.h
+index 65ab1e3..cde470f 100644
+--- a/config.h
++++ b/config.h
+@@ -327,7 +327,7 @@
+ /* CONFIGURE: A list of index filenames to check.  The files are searched
+ ** for in this order.
+ */
+-#define INDEX_NAMES "index.html", "index.htm", "index.xhtml", "index.xht", "Default.htm", "index.cgi"
++#define INDEX_NAMES "cgit.cgi"
+ 
+ /* CONFIGURE: If this is defined then thttpd will automatically generate
+ ** index pages for directories that don't have an explicit index file.
diff --git a/ops/modules/clbot.nix b/ops/modules/clbot.nix
new file mode 100644
index 000000000000..958d321f81ad
--- /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) runCommandNoCC;
+
+  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 (runCommandNoCC "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 = "/run/agenix/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/gerrit-queue.nix b/ops/modules/gerrit-queue.nix
new file mode 100644
index 000000000000..66d584cc3361
--- /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 = "/run/agenix/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..05a783fd6614
--- /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 = "/run/agenix/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..be9e9e966e6b
--- /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/";
+      };
+    };
+  };
+}
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..ef24d61c5767
--- /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 = "whitby.tvl.fyi ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILNh/w4BSKov0jdz3gKBc98tpoLta5bb87fQXWBhAl2I";
+    };
+
+    sanduny = {
+      hostNames = [ "sanduny.tvl.su" ];
+      publicKey = "sanduny.tvl.su ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOag0XhylaTVhmT6HB8EN2Fv5Ymrc4ZfypOXONUkykTX";
+    };
+
+    github = {
+      hostNames = [ "github.com" ];
+      publicKey = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";
+    };
+  };
+}
diff --git a/ops/modules/monorepo-gerrit.nix b/ops/modules/monorepo-gerrit.nix
new file mode 100644
index 000000000000..509500c9139d
--- /dev/null
+++ b/ops/modules/monorepo-gerrit.nix
@@ -0,0 +1,152 @@
+# 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.runCommandNoCC "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"
+    ];
+
+    plugins = with depot.third_party.gerrit_plugins; [
+      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.
+      };
+
+      # 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;
+      };
+    };
+  };
+
+  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..33f196372dbb
--- /dev/null
+++ b/ops/modules/nixery.nix
@@ -0,0 +1,43 @@
+# 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";
+  storagePath = "/var/lib/nixery/${pkgs.nixpkgsCommits.unstable}";
+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.third_party.nixery.nixery-bin}/bin/nixery";
+      };
+
+      environment = {
+        PORT = toString cfg.port;
+        NIXERY_PKGS_PATH = pkgs.path;
+        NIXERY_STORAGE_BACKEND = "filesystem";
+        NIX_TIMEOUT = "60"; # seconds
+        STORAGE_PATH = storagePath;
+      };
+    };
+  };
+}
diff --git a/ops/modules/oauth2_proxy.nix b/ops/modules/oauth2_proxy.nix
new file mode 100644
index 000000000000..423f9010c5d2
--- /dev/null
+++ b/ops/modules/oauth2_proxy.nix
@@ -0,0 +1,53 @@
+# Configuration for oauth2_proxy, which is used as a handler for nginx
+# auth-request setups.
+#
+# This module exports a helper function at
+# `config.services.depot.oauth2_proxy.withAuth` that can be wrapped
+# around nginx server configuration blocks to configure their
+# authentication setup.
+{ config, depot, pkgs, lib, ... }:
+
+let
+  description = "OAuth2 proxy to authenticate TVL services";
+  cfg = config.services.depot.oauth2_proxy;
+  configFile = pkgs.writeText "oauth2_proxy.cfg" ''
+    email_domains = [ "*" ]
+    http_address = "127.0.0.1:${toString cfg.port}"
+    provider = "keycloak-oidc"
+    client_id = "oauth2-proxy"
+    oidc_issuer_url = "https://auth.tvl.fyi/auth/realms/TVL"
+    reverse_proxy = true
+    set_xauthrequest = true
+  '';
+in
+{
+  options.services.depot.oauth2_proxy = {
+    enable = lib.mkEnableOption description;
+
+    port = lib.mkOption {
+      description = "Port to listen on";
+      type = lib.types.int;
+      default = 2884; # "auth"
+    };
+
+    secretsFile = lib.mkOption {
+      type = lib.types.str;
+      description = "EnvironmentFile from which to load secrets";
+      default = "/run/agenix/oauth2_proxy";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.oauth2_proxy = {
+      inherit description;
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        Restart = "always";
+        DynamicUser = true;
+        EnvironmentFile = cfg.secretsFile;
+        ExecStart = "${pkgs.oauth2_proxy}/bin/oauth2-proxy --config ${configFile}";
+      };
+    };
+  };
+}
diff --git a/ops/modules/owothia.nix b/ops/modules/owothia.nix
new file mode 100644
index 000000000000..d11fdd26ecbc
--- /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 = "/run/agenix/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..d57e53e75442
--- /dev/null
+++ b/ops/modules/panettone.nix
@@ -0,0 +1,108 @@
+{ 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 = "/run/agenix/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;
+      };
+    };
+  };
+}
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..275e2809d793
--- /dev/null
+++ b/ops/modules/quassel.nix
@@ -0,0 +1,79 @@
+# 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=/var/lib/acme/${cfg.acmeHost}/full.pem"
+        "--loglevel=${cfg.logLevel}"
+      ];
+
+      serviceConfig = {
+        Restart = "always";
+        User = "quassel";
+        Group = "quassel";
+        StateDirectory = "quassel";
+      };
+    };
+
+    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..cfb185ecd107
--- /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 = "/run/agenix/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..5311b42dd1db
--- /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.31.2";
+
+      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..a6e7372a250a
--- /dev/null
+++ b/ops/modules/tvl-buildkite.nix
@@ -0,0 +1,76 @@
+# 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.runCommandNoCC "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 = "/run/agenix/buildkite-agent-token";
+          hooks.post-command = "${buildkiteHooks}/bin/post-command";
+
+          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..4d574821df44
--- /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 = {
+      binaryCachePublicKeys = [
+        "cache.tvl.su:kjc6KOMupXc1vHVufJUoDUYeLzbwSr9abcAKdn/U1Jk="
+      ];
+
+      binaryCaches = [
+        "https://cache.tvl.su"
+      ];
+    };
+  };
+}
diff --git a/ops/modules/tvl-slapd/default.nix b/ops/modules/tvl-slapd/default.nix
new file mode 100644
index 000000000000..d0d6616e224b
--- /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";
+        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 = "pw-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..988b9eed8af6
--- /dev/null
+++ b/ops/modules/tvl-users.nix
@@ -0,0 +1,94 @@
+# 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;
+      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" ];
+      openssh.authorizedKeys.keys = depot.users.flokli.keys.all;
+    };
+
+    # Temporarily disabled (inactive) users.
+    users.isomer = {
+      isNormalUser = true;
+      extraGroups = [ "git" ];
+      shell = "${pkgs.shadow}/bin/nologin";
+      openssh.authorizedKeys.keys = depot.users.isomer.keys.all;
+    };
+
+    users.riking = {
+      isNormalUser = true;
+      extraGroups = [ "git" ];
+      shell = "${pkgs.shadow}/bin/nologin";
+      openssh.authorizedKeys.keys = depot.users.riking.keys.u2f ++ depot.users.riking.keys.passworded;
+    };
+  };
+
+  environment.systemPackages = with pkgs; [
+    alacritty.terminfo
+    foot.terminfo
+    rxvt_unicode.terminfo
+
+    # TODO(sterni): re-enable when the kitty build is fixed upstreams
+    # kitty.terminfo
+  ];
+}
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..e0c031bf700c
--- /dev/null
+++ b/ops/modules/www/auth.tvl.fyi.nix
@@ -0,0 +1,24 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."auth.tvl.fyi" = {
+      serverName = "auth.tvl.fyi";
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location / {
+          proxy_pass http://localhost:${config.services.keycloak.httpPort};
+          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..470122c395ea
--- /dev/null
+++ b/ops/modules/www/cl.tvl.fyi.nix
@@ -0,0 +1,30 @@
+{ 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;
+        }
+      '';
+    };
+  };
+}
diff --git a/ops/modules/www/code.tvl.fyi.nix b/ops/modules/www/code.tvl.fyi.nix
new file mode 100644
index 000000000000..3f34a9422cb1
--- /dev/null
+++ b/ops/modules/www/code.tvl.fyi.nix
@@ -0,0 +1,45 @@
+{ depot, 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;
+        }
+
+        # 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/images.tvl.fyi.nix b/ops/modules/www/images.tvl.fyi.nix
new file mode 100644
index 000000000000..7d027b2991ab
--- /dev/null
+++ b/ops/modules/www/images.tvl.fyi.nix
@@ -0,0 +1,22 @@
+{ config, ... }:
+
+{
+  imports = [
+    ./base.nix
+  ];
+
+  config = {
+    services.nginx.virtualHosts."images.tvl.fyi" = {
+      serverName = "images.tvl.fyi";
+      serverAliases = [ "images.tvl.su" ];
+      enableACME = true;
+      forceSSL = true;
+
+      extraConfig = ''
+        location / {
+          proxy_pass http://localhost:${toString config.services.depot.nixery.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/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..2bb6093c1472
--- /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.port}";
+      };
+    };
+  };
+}
diff --git a/ops/modules/www/tazj.in.nix b/ops/modules/www/tazj.in.nix
new file mode 100644
index 000000000000..7d658a5ec4c1
--- /dev/null
+++ b/ops/modules/www/tazj.in.nix
@@ -0,0 +1,40 @@
+# 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;
+
+      extraConfig = ''
+        ${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;
+        }
+
+        # 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/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"}";
+    };
+  };
+}