diff --git a/modules/module-list.nix b/modules/module-list.nix
index 1be337d0..a226bbaa 100644
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -44,7 +44,7 @@
   ./launchd
   ./services/activate-system
   ./services/autossh.nix
-  ./services/buildkite-agent.nix
+  ./services/buildkite-agents.nix
   ./services/chunkwm.nix
   ./services/cachix-agent.nix
   ./services/dnsmasq.nix
diff --git a/modules/services/buildkite-agent.nix b/modules/services/buildkite-agent.nix
deleted file mode 100644
index ace89ec3..00000000
--- a/modules/services/buildkite-agent.nix
+++ /dev/null
@@ -1,253 +0,0 @@
-{ config, lib, pkgs, ... }:
-
-with lib;
-
-let
-  cfg = config.services.buildkite-agent;
-
-  mkHookOption = { name, description, example ? null }: {
-    inherit name;
-    value = mkOption {
-      default = null;
-      inherit description;
-      type = types.nullOr types.lines;
-    } // (if example == null then {} else { inherit example; });
-  };
-  mkHookOptions = hooks: listToAttrs (map mkHookOption hooks);
-
-  hooksDir = let
-    mkHookEntry = name: value: {
-      inherit name;
-      path = pkgs.writeScript "buildkite-agent-hook-${name}" ''
-        #! ${pkgs.stdenv.shell}
-        set -e
-        ${value}
-      '';
-    };
-  in pkgs.linkFarm "buildkite-agent-hooks"
-    (mapAttrsToList mkHookEntry (filterAttrs (n: v: v != null) cfg.hooks));
-
-in
-
-{
-  options = {
-    services.buildkite-agent.enable = mkEnableOption "buildkite-agent";
-
-    services.buildkite-agent.package = mkOption {
-      default = pkgs.buildkite-agent;
-      defaultText = "pkgs.buildkite-agent";
-      description = "Which buildkite-agent derivation to use";
-      type = types.package;
-    };
-
-    services.buildkite-agent.dataDir = mkOption {
-      default = "/var/lib/buildkite-agent";
-      description = "The workdir for the agent";
-      type = types.str;
-    };
-
-    services.buildkite-agent.runtimePackages = mkOption {
-      default = [ pkgs.bash pkgs.nix ];
-      defaultText = "[ pkgs.bash pkgs.nix ]";
-      description = "Add programs to the buildkite-agent environment";
-      type = types.listOf types.package;
-    };
-
-    services.buildkite-agent.tokenPath = mkOption {
-      type = types.path;
-      description = ''
-        The token from your Buildkite "Agents" page.
-
-        A run-time path to the token file, which is supposed to be provisioned
-        outside of Nix store.
-      '';
-    };
-
-    services.buildkite-agent.name = mkOption {
-      type = types.str;
-      default = "%hostname-%n";
-      description = ''
-        The name of the agent.
-      '';
-    };
-
-    services.buildkite-agent.meta-data = mkOption {
-      type = types.str;
-      default = "";
-      example = "queue=default,docker=true,ruby2=true";
-      description = ''
-        Meta data for the agent. This is a comma-separated list of
-        <code>key=value</code> pairs.
-      '';
-    };
-
-    services.buildkite-agent.extraConfig = mkOption {
-      type = types.lines;
-      default = "";
-      example = "debug=true";
-      description = ''
-        Extra lines to be added verbatim to the configuration file.
-      '';
-    };
-    services.buildkite-agent.preCommands = mkOption {
-      type = types.lines;
-      default = "";
-      description = ''
-        Extra commands to run before starting buildkite.
-      '';
-    };
-
-    services.buildkite-agent.openssh =
-      { privateKeyPath = mkOption {
-          type = types.path;
-          description = ''
-            Private agent key.
-
-            A run-time path to the key file, which is supposed to be provisioned
-            outside of Nix store.
-          '';
-        };
-        publicKeyPath = mkOption {
-          type = types.path;
-          description = ''
-            Public agent key.
-
-            A run-time path to the key file, which is supposed to be provisioned
-            outside of Nix store.
-          '';
-        };
-      };
-
-    services.buildkite-agent.hooks = mkHookOptions [
-      { name = "checkout";
-        description = ''
-          The `checkout` hook script will replace the default checkout routine of the
-          bootstrap.sh script. You can use this hook to do your own SCM checkout
-          behaviour
-        ''; }
-      { name = "command";
-        description = ''
-          The `command` hook script will replace the default implementation of running
-          the build command.
-        ''; }
-      { name = "environment";
-        description = ''
-          The `environment` hook will run before all other commands, and can be used
-          to set up secrets, data, etc. Anything exported in hooks will be available
-          to the build script.
-
-          Note: the contents of this file will be copied to the world-readable
-          Nix store.
-        '';
-        example = ''
-          export SECRET_VAR=`head -1 /run/keys/secret`
-        ''; }
-      { name = "post-artifact";
-        description = ''
-          The `post-artifact` hook will run just after artifacts are uploaded
-        ''; }
-      { name = "post-checkout";
-        description = ''
-          The `post-checkout` hook will run after the bootstrap script has checked out
-          your projects source code.
-        ''; }
-      { name = "post-command";
-        description = ''
-          The `post-command` hook will run after the bootstrap script has run your
-          build commands
-        ''; }
-      { name = "pre-artifact";
-        description = ''
-          The `pre-artifact` hook will run just before artifacts are uploaded
-        ''; }
-      { name = "pre-checkout";
-        description = ''
-          The `pre-checkout` hook will run just before your projects source code is
-          checked out from your SCM provider
-        ''; }
-      { name = "pre-command";
-        description = ''
-          The `pre-command` hook will run just before your build command runs
-        ''; }
-      { name = "pre-exit";
-        description = ''
-          The `pre-exit` hook will run just before your build job finishes
-        ''; }
-    ];
-  };
-
-  config = mkIf config.services.buildkite-agent.enable {
-    users.users.buildkite-agent =
-      { name = "buildkite-agent";
-        home = cfg.dataDir;
-        description = "Buildkite agent user";
-      };
-    users.groups.buildkite-agent =
-      { name = "buildkite-agent";
-        description = "Buildkite agent user group";
-      };
-
-    environment.systemPackages = [ cfg.package ];
-
-    launchd.daemons.buildkite-agent =
-      {
-        path = cfg.runtimePackages ++ [ pkgs.coreutils cfg.package ]
-          ++ (if pkgs.stdenv.isDarwin then [ pkgs.darwin.DarwinTools ] else []);
-        environment = {
-          HOME = cfg.dataDir;
-          NIX_SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
-        } // (if config.nix.useDaemon then { NIX_REMOTE = "daemon"; } else {});
-
-        ## NB: maximum care is taken so that secrets (ssh keys and the CI token)
-        ##     don't end up in the Nix store.
-        script = let
-          sshDir = "${cfg.dataDir}/.ssh";
-        in
-          ''
-            mkdir -m 0700 -p "${sshDir}"
-            cp -f "${toString cfg.openssh.privateKeyPath}" "${sshDir}/id_rsa"
-            cp -f "${toString cfg.openssh.publicKeyPath}"  "${sshDir}/id_rsa.pub"
-            chmod 600 "${sshDir}"/id_rsa*
-
-            cat > "${cfg.dataDir}/buildkite-agent.cfg" <<EOF
-            token="$(cat ${toString cfg.tokenPath})"
-            name="${cfg.name}"
-            meta-data="${cfg.meta-data}"
-            build-path="${cfg.dataDir}/builds"
-            hooks-path="${hooksDir}"
-            ${cfg.extraConfig}
-            EOF
-
-            # Secrets exist in the buildkite-agent home directory
-            chmod 750 "${cfg.dataDir}"
-            chmod 640 "${cfg.dataDir}/buildkite-agent.cfg"
-
-            ${cfg.preCommands}
-
-            exec buildkite-agent start --config "${cfg.dataDir}/buildkite-agent.cfg"
-          '';
-
-        serviceConfig = {
-          ProcessType = "Interactive";
-          ThrottleInterval = 30;
-
-          # The combination of KeepAlive.NetworkState and WatchPaths
-          # will ensure that buildkite-agent is started on boot, but
-          # after networking is available (so the hostname is
-          # correct).
-          RunAtLoad = true;
-          KeepAlive.NetworkState = true;
-          WatchPaths = [
-            "/etc/resolv.conf"
-            "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist"
-          ];
-
-          GroupName = "buildkite-agent";
-          UserName = "buildkite-agent";
-          WorkingDirectory = config.users.users.buildkite-agent.home;
-          StandardErrorPath = "${cfg.dataDir}/buildkite-agent.log";
-          StandardOutPath = "${cfg.dataDir}/buildkite-agent.log";
-        };
-      };
-  };
-}
diff --git a/modules/services/buildkite-agents.nix b/modules/services/buildkite-agents.nix
new file mode 100644
index 00000000..a8931bd1
--- /dev/null
+++ b/modules/services/buildkite-agents.nix
@@ -0,0 +1,293 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.buildkite-agents;
+  mdDoc = lib.mdDoc or (x: "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+  literalMD = lib.literalMD or (x: lib.literalDocBook "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+
+  mkHookOption = { name, description, example ? null }: {
+    inherit name;
+    value = mkOption {
+      default = null;
+      description = mdDoc description;
+      type = types.nullOr types.lines;
+    } // (if example == null then {} else { inherit example; });
+  };
+  mkHookOptions = hooks: listToAttrs (map mkHookOption hooks);
+
+  hooksDir = cfg: let
+    mkHookEntry = name: value: ''
+      cat > $out/${name} <<'EOF'
+      #! ${pkgs.runtimeShell}
+      set -e
+      ${value}
+      EOF
+      chmod 755 $out/${name}
+    '';
+  in pkgs.runCommand "buildkite-agent-hooks" { preferLocalBuild = true; } ''
+    mkdir $out
+    ${concatStringsSep "\n" (mapAttrsToList mkHookEntry (filterAttrs (n: v: v != null) cfg.hooks))}
+  '';
+
+  buildkiteOptions = { name ? "", config, ... }: {
+    options = {
+      enable = mkOption {
+        default = true;
+        type = types.bool;
+        description = mdDoc "Whether to enable this buildkite agent";
+      };
+
+      package = mkOption {
+        default = pkgs.buildkite-agent;
+        defaultText = literalExpression "pkgs.buildkite-agent";
+        description = mdDoc "Which buildkite-agent derivation to use";
+        type = types.package;
+      };
+
+      dataDir = mkOption {
+        default = "/var/lib/buildkite-agent-${name}";
+        description = mdDoc "The workdir for the agent";
+        type = types.str;
+      };
+
+      runtimePackages = mkOption {
+        default = [ pkgs.bash pkgs.gnutar pkgs.gzip pkgs.git pkgs.nix ];
+        defaultText = literalExpression "[ pkgs.bash pkgs.gnutar pkgs.gzip pkgs.git pkgs.nix ]";
+        description = mdDoc "Add programs to the buildkite-agent environment";
+        type = types.listOf types.package;
+      };
+
+      tokenPath = mkOption {
+        type = types.path;
+        description = mdDoc ''
+          The token from your Buildkite "Agents" page.
+
+          A run-time path to the token file, which is supposed to be provisioned
+          outside of Nix store.
+        '';
+      };
+
+      name = mkOption {
+        type = types.str;
+        default = "%hostname-${name}-%n";
+        description = mdDoc ''
+          The name of the agent as seen in the buildkite dashboard.
+        '';
+      };
+
+      tags = mkOption {
+        type = types.attrsOf (types.either types.str (types.listOf types.str));
+        default = {};
+        example = { queue = "default"; docker = "true"; ruby2 ="true"; };
+        description = mdDoc ''
+          Tags for the agent.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.lines;
+        default = "";
+        example = "debug=true";
+        description = mdDoc ''
+          Extra lines to be added verbatim to the configuration file.
+        '';
+      };
+
+      preCommands = mkOption {
+        type = types.lines;
+        default = "";
+        description = ''
+          Extra commands to run before starting buildkite.
+        '';
+      };
+
+      privateSshKeyPath = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        ## maximum care is taken so that secrets (ssh keys and the CI token)
+        ## don't end up in the Nix store.
+        apply = final: if final == null then null else toString final;
+
+        description = mdDoc ''
+          OpenSSH private key
+
+          A run-time path to the key file, which is supposed to be provisioned
+          outside of Nix store.
+        '';
+      };
+
+      hooks = mkHookOptions [
+        { name = "checkout";
+          description = ''
+            The `checkout` hook script will replace the default checkout routine of the
+            bootstrap.sh script. You can use this hook to do your own SCM checkout
+            behaviour
+          ''; }
+        { name = "command";
+          description = ''
+            The `command` hook script will replace the default implementation of running
+            the build command.
+          ''; }
+        { name = "environment";
+          description = ''
+            The `environment` hook will run before all other commands, and can be used
+            to set up secrets, data, etc. Anything exported in hooks will be available
+            to the build script.
+
+            Note: the contents of this file will be copied to the world-readable
+            Nix store.
+          '';
+          example = ''
+            export SECRET_VAR=`head -1 /run/keys/secret`
+          ''; }
+        { name = "post-artifact";
+          description = ''
+            The `post-artifact` hook will run just after artifacts are uploaded
+          ''; }
+        { name = "post-checkout";
+          description = ''
+            The `post-checkout` hook will run after the bootstrap script has checked out
+            your projects source code.
+          ''; }
+        { name = "post-command";
+          description = ''
+            The `post-command` hook will run after the bootstrap script has run your
+            build commands
+          ''; }
+        { name = "pre-artifact";
+          description = ''
+            The `pre-artifact` hook will run just before artifacts are uploaded
+          ''; }
+        { name = "pre-checkout";
+          description = ''
+            The `pre-checkout` hook will run just before your projects source code is
+            checked out from your SCM provider
+          ''; }
+        { name = "pre-command";
+          description = ''
+            The `pre-command` hook will run just before your build command runs
+          ''; }
+        { name = "pre-exit";
+          description = ''
+            The `pre-exit` hook will run just before your build job finishes
+          ''; }
+      ];
+
+      hooksPath = mkOption {
+        type = types.path;
+        default = hooksDir config;
+        defaultText = literalMD "generated from {option}`services.buildkite-agents.<name>.hooks`";
+        description = mdDoc ''
+          Path to the directory storing the hooks.
+          Consider using {option}`services.buildkite-agents.<name>.hooks.<name>`
+          instead.
+        '';
+      };
+
+      shell = mkOption {
+        type = types.str;
+        default = "${pkgs.bash}/bin/bash -e -c";
+        defaultText = literalExpression ''"''${pkgs.bash}/bin/bash -e -c"'';
+        description = mdDoc ''
+          Command that buildkite-agent 3 will execute when it spawns a shell.
+        '';
+      };
+    };
+  };
+  enabledAgents = lib.filterAttrs (n: v: v.enable) cfg;
+  mapAgents = function: lib.mkMerge (lib.mapAttrsToList function enabledAgents);
+in
+{
+  options.services.buildkite-agents = mkOption {
+    type = types.attrsOf (types.submodule buildkiteOptions);
+    default = {};
+    description = mdDoc ''
+      Attribute set of buildkite agents.
+      The attribute key is combined with the hostname and a unique integer to
+      create the final agent name. This can be overridden by setting the `name`
+      attribute.
+    '';
+  };
+
+  config.users.users = mapAgents (name: cfg: {
+    "buildkite-agent-${name}" = {
+      name = "buildkite-agent-${name}";
+      home = cfg.dataDir;
+      createHome = true;
+      description = "Buildkite agent user";
+    };
+  });
+  config.users.groups = mapAgents (name: cfg: {
+    "buildkite-agent-${name}" = {};
+  });
+
+  config.launchd.daemons = mapAgents (name: cfg: {
+    "buildkite-agent-${name}" =
+      { path = cfg.runtimePackages ++ [ cfg.package pkgs.coreutils pkgs.darwin.DarwinTools ];
+        environment = {
+          HOME = cfg.dataDir;
+        }// (if config.nix.useDaemon then { NIX_REMOTE = "daemon"; } else {});
+
+        ## NB: maximum care is taken so that secrets (ssh keys and the CI token)
+        ##     don't end up in the Nix store.
+        script = let
+          sshDir = "${cfg.dataDir}/.ssh";
+          tagStr = lib.concatStringsSep "," (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.tags);
+        in
+          optionalString (cfg.privateSshKeyPath != null) ''
+            mkdir -m 0700 -p "${sshDir}"
+            install -m600 "${toString cfg.privateSshKeyPath}" "${sshDir}/id_rsa"
+          '' + ''
+            cat > "${cfg.dataDir}/buildkite-agent.cfg" <<EOF
+            token="$(cat ${toString cfg.tokenPath})"
+            name="${cfg.name}"
+            shell="${cfg.shell}"
+            tags="${tagStr}"
+            build-path="${cfg.dataDir}/builds"
+            hooks-path="${cfg.hooksPath}"
+            ${cfg.extraConfig}
+            EOF
+
+            ${cfg.preCommands}
+
+            ${cfg.package}/bin/buildkite-agent start --config ${cfg.dataDir}/buildkite-agent.cfg
+          '';
+
+        serviceConfig = {
+          ProcessType = "Interactive";
+          ThrottleInterval = 30;
+
+          # The combination of KeepAlive.NetworkState and WatchPaths
+          # will ensure that buildkite-agent is started on boot, but
+          # after networking is available (so the hostname is
+          # correct).
+          RunAtLoad = true;
+          WatchPaths = [
+            "/etc/resolv.conf"
+            "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist"
+          ];
+
+          GroupName = "buildkite-agent-${name}";
+          UserName = "buildkite-agent-${name}";
+          WorkingDirectory = config.users.users."buildkite-agent-${name}".home;
+          StandardErrorPath = "${cfg.dataDir}/buildkite-agent.log";
+          StandardOutPath = "${cfg.dataDir}/buildkite-agent.log";
+        };
+      };
+  });
+
+  config.assertions = mapAgents (name: cfg: [
+      { assertion = cfg.hooksPath == (hooksDir cfg)  || all (v: v == null) (attrValues cfg.hooks);
+        message = ''
+          Options `services.buildkite-agents.${name}.hooksPath' and
+          `services.buildkite-agents.${name}.hooks.<name>' are mutually exclusive.
+        '';
+      }
+  ]);
+
+  imports = [
+    (mkRemovedOptionModule [ "services" "buildkite-agent"] "services.buildkite-agent has been moved to an attribute set at services.buildkite-agents")
+  ];
+}
diff --git a/tests/services-buildkite-agent.nix b/tests/services-buildkite-agent.nix
index 4ca89b8f..557aad23 100644
--- a/tests/services-buildkite-agent.nix
+++ b/tests/services-buildkite-agent.nix
@@ -6,22 +6,21 @@ let
 in
 
 {
-  services.buildkite-agent = {
+  services.buildkite-agents.test = {
     enable = true;
     package = buildkite-agent;
     extraConfig = "yolo=1";
-    openssh.privateKeyPath = "/dev/null";
-    openssh.publicKeyPath = "/dev/null";
+    privateSshKeyPath = "/dev/null";
     hooks.command = "echo test hook";
     inherit tokenPath;
   };
 
   test = ''
-    echo "checking buildkite-agent service in /Library/LaunchDaemons" >&2
-    grep "org.nixos.buildkite-agent" ${config.out}/Library/LaunchDaemons/org.nixos.buildkite-agent.plist
+    echo "checking buildkite-agent-test service in /Library/LaunchDaemons" >&2
+    grep "org.nixos.buildkite-agent-test" ${config.out}/Library/LaunchDaemons/org.nixos.buildkite-agent-test.plist
 
     echo "checking creation of buildkite-agent service config" >&2
-    script=$(cat ${config.out}/Library/LaunchDaemons/org.nixos.buildkite-agent.plist | awk -F'[< ]' '$3 ~ "^/nix/store/.*" {print $3}')
+    script=$(cat ${config.out}/Library/LaunchDaemons/org.nixos.buildkite-agent-test.plist | awk -F'[< ]' '$3 ~ "^/nix/store/.*" {print $3}')
     grep "yolo=1" "$script"
     grep "${tokenPath}" "$script"