diff --git a/docs/source/dev/README.rst b/docs/source/dev/README.rst
index 3722ebd03b10bacaf459820d1f59bc7ccb07396b..14d2de31f932a0aa50545643e30c679c36696e19 100644
--- a/docs/source/dev/README.rst
+++ b/docs/source/dev/README.rst
@@ -39,6 +39,24 @@ Inside that is bin/nixos-test-driver which gives you a kind of REPL for interact
 The kind of `Python in this testScript <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/blob/78881a3/nixos/modules/tests/private-storage.nix#L180>`_ is what you can enter into this REPL.
 Consult the `official documentation on NixOS Tests <https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests>`_ for more information.
 
+Updatings Pins
+--------------
+
+Nixpkgs
+```````
+
+To update the version of NixOS we deploy with, run:
+
+.. code: shell
+
+   nix-shell --run 'update-nixpkgs'
+
+That will update ``nixpkgs-2015.json`` to the latest release on the nixos-21.05 channel.
+
+To update the channel, the script will need to be updated,
+along with the filenames that have the channel in them.
+
+
 Architecture overview
 ---------------------
 
diff --git a/nixpkgs-2105.json b/nixpkgs-2105.json
index 76950db1870cb62d68e655f5ca4be90f3fcbf6be..f79aa88bc0bb97b26c4668ac1d2c4efcdb25b9fb 100644
--- a/nixpkgs-2105.json
+++ b/nixpkgs-2105.json
@@ -1,4 +1,5 @@
-{ "name": "release2105"
-, "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.804.5de44c15758/nixexprs.tar.xz"
-, "sha256": "002zvc16hyrbs0icx1qj255c9dqjpdxx4bhhfjndlj3kwn40by0m"
+{
+  "name": "release2105",
+  "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3065.b3083bc6933/nixexprs.tar.xz",
+  "sha256": "186vni8rij8bhd6n5n9h55jf2x78v9zdy2gn9v4cpjhajp4pvzm0"
 }
diff --git a/shell.nix b/shell.nix
index fe76c1e7878628cff2a5a1fcdd8d027b2c817a7a..a5741377eec5ebd4b8862a0ea47e15edfdac2731 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,7 +1,10 @@
 let
   release2105 = import ./nixpkgs-2105.nix { };
 in
-{ pkgs ? release2105 }:
+{ pkgs ? release2105, lib ? pkgs.lib, python ? pkgs.python3 }:
+let
+  tools = pkgs.callPackage ./tools {};
+in
 pkgs.mkShell {
   # When a path (such as `pkgs.path`) is interpolated into a string then nix
   # first adds that path to the store, and then interpolates the store path
@@ -15,7 +18,10 @@ pkgs.mkShell {
   shellHook = ''
     export NIX_PATH="nixpkgs=${builtins.toString pkgs.path}";
   '';
+  # Run the shellHook from tools
+  inputsFrom = [tools];
   buildInputs = [
+    tools
     pkgs.morph
     pkgs.jp
   ];
diff --git a/tools/default.nix b/tools/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..f9a0b1ff8d902f3072886939ad11e1e223ffbb7e
--- /dev/null
+++ b/tools/default.nix
@@ -0,0 +1,49 @@
+{ pkgs, lib, makeWrapper, ... }:
+let
+  python = pkgs.python3;
+  # This is a python envionment that has the dependencies
+  # for the development python scripts we use, and the
+  # helper library.
+  python-env = python.buildEnv.override {
+    extraLibs = [ python.pkgs.httpx ];
+    # Add `.pth` file pointing at the directory containg our helper library.
+    # This will get added to `sys.path` by `site.py`.
+    # See https://docs.python.org/3/library/site.html
+    postBuild = ''
+      echo ${lib.escapeShellArg ./pylib} > $out/${lib.escapeShellArg python.sitePackages}/tools.pth
+    '';
+  };
+  python-commands = [
+    ./update-nixpkgs
+  ];
+in
+  # This derivation creates a package that wraps our tools to setup an environment
+  # with there dependencies available.
+pkgs.runCommand "ps_tools" {
+  nativeBuildInputs = [ makeWrapper ];
+  shellHook = ''
+    # Only display the help if we are running an interactive shell.
+    if [[ $- == *i* ]]; then
+      cat <<MOTD
+    Tools (pass --help for details):
+    ${lib.concatStringsSep "\n" (map (path:
+        "- ${baseNameOf path}"
+    ) python-commands)}
+    MOTD
+    fi
+  '';
+  } ''
+    mkdir -p $out/bin
+    ${lib.concatStringsSep "\n" (map (path:
+      let
+        baseName = baseNameOf path;
+        # We use toString so that we wrap the in-tree scripts, rather than copying
+        # them to the nix-store. This means that we don't need to run nix-shell again
+        # to pick up changes.
+        sourcePath = toString path;
+      in
+      # makeWrapper <executable> <wrapperfile> <args>
+      # See https://nixos.org/manual/nixpkgs/stable/#fun-makeWrapper
+      "makeWrapper ${python-env}/bin/python $out/bin/${baseName} --add-flags ${sourcePath}"
+      ) python-commands)}
+  ''
diff --git a/tools/pylib/README.rst b/tools/pylib/README.rst
new file mode 100644
index 0000000000000000000000000000000000000000..083f6620d93ac30303b805cb4098e93804323aef
--- /dev/null
+++ b/tools/pylib/README.rst
@@ -0,0 +1,2 @@
+This directory contains a python package of helper functions used by the scripts in ``tools/``.
+To get this on the python path, run ``nix-shell`` in the root of the repository.
diff --git a/tools/pylib/ps_tools/__init__.py b/tools/pylib/ps_tools/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..278ef0f1a765a39f5e8a46adf99621b6b238dc5e
--- /dev/null
+++ b/tools/pylib/ps_tools/__init__.py
@@ -0,0 +1,35 @@
+"""
+Helpers for development and CI scripts.
+"""
+from __future__ import annotations
+
+import subprocess
+
+
+def get_url_hash(hash_type, name, url) -> dict[str, str]:
+    """
+    Get the nix hash of the given URL.
+
+    :returns: Dictionary of arguments suitable to pass to :nix:`pkgs.fetchzip`
+        or a function derived from it (such as :nix:`pkgs.fetchFromGitLab`)
+        to specify the hash.
+    """
+    output = subprocess.run(
+        [
+            "nix-prefetch-url",
+            "--type",
+            hash_type,
+            "--unpack",
+            "--name",
+            name,
+            url,
+        ],
+        capture_output=True,
+        check=True,
+        encoding="utf-8",
+    )
+
+    return {
+        "outputHashAlgo": hash_type,
+        "outputHash": output.stdout.strip(),
+    }
diff --git a/tools/update-nixpkgs b/tools/update-nixpkgs
new file mode 100755
index 0000000000000000000000000000000000000000..09c823b0a419b5937d4953337b94a26c4b502e32
--- /dev/null
+++ b/tools/update-nixpkgs
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+
+import argparse
+import json
+from pathlib import Path
+
+import httpx
+from ps_tools import get_url_hash
+
+# We pass this to builtins.fetchTarball which only supports sha256
+HASH_TYPE = "sha256"
+
+DEFAULT_CHANNEL = "nixos-21.05"
+CHANNEL_URL_TEMPLATE = "https://channels.nixos.org/{channel}/nixexprs.tar.xz"
+
+
+def get_nixos_channel_url(*, channel):
+    """
+    Get the URL for the current release of the given nixos channel.
+
+    `https://channels.nixos.org/<channel>` redirects to the path on
+    `https://releases.nixos.org` that corresponds to the current release
+    of that channel. This captures that redirect, so we can pin against
+    the release.
+    """
+    response = httpx.head(
+        CHANNEL_URL_TEMPLATE.format(channel=channel), allow_redirects=False
+    )
+    response.raise_for_status()
+    assert response.is_redirect
+    return str(response.next_request.url)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Update a pinned nixos repository.")
+    parser.add_argument(
+        "repo_file",
+        metavar="repo-file",
+        nargs="?",
+        default=Path(__file__).parent.with_name("nixpkgs-2105.json"),
+        type=Path,
+        help="JSON file with pinned configuration.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+    )
+    parser.set_defaults(channel=DEFAULT_CHANNEL)
+    args = parser.parse_args()
+
+    repo_file = args.repo_file
+    config = json.loads(repo_file.read_text())
+
+    config["url"] = get_nixos_channel_url(channel=args.channel)
+    hash_data = get_url_hash(HASH_TYPE, name=config["name"], url=config["url"])
+    config["sha256"] = hash_data["outputHash"]
+
+    output = json.dumps(config, indent=2)
+    if args.dry_run:
+        print(output)
+    else:
+        repo_file.write_text(output)
+
+
+if __name__ == "__main__":
+    main()