diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b91f7d5f175a32a49a5ed0788a87146d9b86c3f3..4e68fd45b354283d26e808a7b68eb1d38adf58a4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -46,8 +46,7 @@ deploy-to-staging:
     name: "staging"
     url: "https://privatestorage-staging.com/"
   script:
-    - echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
-    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the $CI_ENVIRONMENT_NAME environment."
+    - "./ci-tools/deploy-to-staging"
 
 deploy-to-production:
   stage: "deploy"
diff --git a/ci-tools/deploy-to-staging b/ci-tools/deploy-to-staging
new file mode 100644
index 0000000000000000000000000000000000000000..47cdafa4c1422631159baef75d607f176eaf0def
--- /dev/null
+++ b/ci-tools/deploy-to-staging
@@ -0,0 +1,27 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p jp
+
+set -euxo pipefail
+
+GRIDNAME="staging"
+
+# Tell one node to update itself.
+update_yourself() {
+    node=$1
+    shift
+
+    ssh -i deploy_key "${node}"
+}
+
+# Find the names of all hosts that belong to this grid.  This list includes
+# one extra string, "network", which is morph configuration stuff and we need
+# to filter out later.
+NODES=$(nix eval --json '(builtins.attrNames (import ./morph/${GRIDNAME}/grid.nix))')
+
+# Tell every system in the network to update itself.
+for node in ${NODES}; do
+    if [ "${node}" = "network" ]; then
+	continue
+    fi
+    update_yourself "${node}"
+fi
diff --git a/nixos/modules/deployment.nix b/nixos/modules/deployment.nix
new file mode 100644
index 0000000000000000000000000000000000000000..a12ff05406c8a712f4c57bb3cb7ee6a1c1404532
--- /dev/null
+++ b/nixos/modules/deployment.nix
@@ -0,0 +1,18 @@
+# A NixOS module which enables remotely-triggered deployment updates.
+{ config, ... }:
+let
+  # Compute an authorized_keys line that allows the holder of a certain key to
+  # execute a certain command *only*.
+  restrictedKey = pubKey: command: "restrict,command=\"${command}\" ${pubKey}";
+in {
+  options = {
+  };
+
+  config = {
+    users.users.deployment = {
+      openssh.authorizedKeys.keys = [
+        restrictedKey cfg.deployKey ./update-deployment
+      ];
+    };
+  };
+}
diff --git a/nixos/modules/update-deployment b/nixos/modules/update-deployment
new file mode 100644
index 0000000000000000000000000000000000000000..7685423eaf6e02d61a56cda7073041da123b08b9
--- /dev/null
+++ b/nixos/modules/update-deployment
@@ -0,0 +1,19 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p morph
+
+set -euxo pipefail
+
+CHECKOUT="/run/user/$(id --user)/PrivateStorageio"
+GRIDNAME="staging"
+REPO="https://whetstone.privatestorage.io/privatestorage/PrivateStorageio.git"
+
+if [ -e "${CHECKOUT}" ]; then
+    git -C "${CHECKOUT}" pull
+else
+    git clone "${REPO}" "${CHECKOUT}"
+    # Check out the right branch ... which also happens to be named after this
+    # grid (or maybe this grid is named after the branch).
+    git -C "${CHECKOUT}" checkout "${GRIDNAME}"
+fi
+
+morph deploy "${CHECKOUT}"/morph/grid/"${GRIDNAME}"/grid.nix switch --on "$(hostname)"