diff --git a/.github/workflows/nix-flake.yml b/.github/workflows/nix-flake.yml
index 4dfb6453..af9582ac 100644
--- a/.github/workflows/nix-flake.yml
+++ b/.github/workflows/nix-flake.yml
@@ -19,4 +19,3 @@ jobs:
         authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
     - run: nix flake show
     - run: nix flake check --print-build-logs
-    - run: nix build --print-build-logs
diff --git a/README.md b/README.md
index f95defa5..7dcb8a03 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,20 @@ Build and install
 
 Refer to [INSTALL.md](./INSTALL.md)
 
+Testing
+-------
+
+Besides manual testing, which is also encouraged, there are also a number of
+checks defined that are used for CI (e.g. github actions).
+
+These checks can be listed using `nix flake show`. You can, for example, test
+the current state of the repo or test every patch before publishing them.
+
+```console
+$ nix flake check
+$ git rev-list origin/master.. | xargs -I{} nix flake check "git+file://$(pwd)?rev={}"
+```
+
 Bug reports and contributions
 -----------------------------
 
diff --git a/checks/README.md b/checks/README.md
new file mode 100644
index 00000000..1deecea6
--- /dev/null
+++ b/checks/README.md
@@ -0,0 +1,72 @@
+# NixOS tests
+
+Any `*.sh` file in this directory will be run in a NixOS VM for basic
+functionality testing as part of CI. To list all outputs, including the checks,
+you can use this command:
+
+```console
+$ nix flake show
+```
+
+You can also run these tests locally by running `nix flake check`. To run one
+specific test you can use `nix build` like this:
+
+```console
+$ nix build ".#checks.x86_64-linux.subvolume"
+```
+
+With the flag `-L`/`--print-build-logs` outputs are shown fully as checks are
+executing. Additionally, if the specific check has already been run locally, you
+can view the log for the check or force another run with the following:
+
+```console
+$ nix log .#checks.x86_64-linux.subvolume
+$ nix build --rebuild .#checks.x86_64-linux.subvolume
+```
+
+If you need any more packages inside of the VM for a test, you can add them to
+`environment.systemPackages` in `default.nix`. If you're unsure about the
+package you need, [NixOS package search] may be able to help.
+
+For more information about the NixOS testing library see the
+[testing wiki article].
+
+## Kernel version inside VM
+
+By default `linuxPackages_latest` from nixpkgs is used in the testing VM. This
+is the latest stable kernel version available in the nixpkgs revision. Updating
+the nixpkgs flake input may update the used kernel. A custom-built kernel can be
+used as well but with added build times in CI.
+
+## Adding new tests
+
+The easiest way to add new tests is of course to copy an existing test and adapt
+it accordingly. Importantly, for nix to see a file as part of the sources, the
+file needs to be in the git index. It doesn't have to be committed to the repo
+just yet but you need to `git add` it. If `git ls-files` lists the file, nix
+will also see it.
+
+## Interactive debugging of tests
+
+When writing a new test or experiencing a difficult to understand test failure,
+an interactive login can be very handy. This can be achieved by building the
+`driverInteractive` attribute of the check, for example like this:
+
+```console
+$ nix build .#checks.x86_64-linux.subvolume.driverInteractive
+```
+
+The `nix build` will create a symlink in your working directory called `result`
+which leads to a script that launches the VM interactively:
+
+```console
+$ ./result/bin/nixos-test-driver
+```
+
+There is more information about this in the NixOS manual under
+[running tests interactively].
+
+[Linux wiki article]: https://wiki.nixos.org/wiki/Linux_kernel
+[NixOS package search]: https://search.nixos.org
+[running tests interactively]: https://nixos.org/manual/nixos/stable/#sec-running-nixos-tests-interactively
+[testing wiki article]: https://wiki.nixos.org/wiki/NixOS_Testing_library
diff --git a/checks/default.nix b/checks/default.nix
new file mode 100644
index 00000000..42581433
--- /dev/null
+++ b/checks/default.nix
@@ -0,0 +1,52 @@
+{ pkgs }:
+let
+  inherit (builtins) baseNameOf readDir;
+  inherit (pkgs.lib)
+    filterAttrs
+    genAttrs
+    hasSuffix
+    mapAttrsToList
+    removeSuffix
+    ;
+
+  scriptName = shFile: removeSuffix ".sh" (baseNameOf shFile);
+
+  scriptNames = mapAttrsToList (n: v: scriptName n) (
+    filterAttrs (n: v: v == "regular" && hasSuffix ".sh" n) (readDir ./.)
+  );
+
+  mkTest =
+    name:
+    pkgs.testers.runNixOSTest {
+      inherit name;
+
+      nodes.machine =
+        { pkgs, ... }:
+        {
+          virtualisation.emptyDiskImages = [
+            4096
+            1024
+          ];
+          boot.supportedFilesystems = [ "bcachefs" ];
+          boot.kernelPackages = pkgs.linuxPackages_latest;
+
+          # Add any packages you need inside test scripts here
+          environment.systemPackages = with pkgs; [
+            f3
+            genpass
+            keyutils
+          ];
+
+          environment.variables = {
+            BCACHEFS_LOG = "trace";
+            RUST_BACKTRACE = "full";
+          };
+        };
+
+      testScript = ''
+        machine.succeed("modprobe bcachefs")
+        machine.succeed("${./${name}.sh} 1>&2")
+      '';
+    };
+in
+genAttrs scriptNames mkTest
diff --git a/checks/encrypted-multidev.sh b/checks/encrypted-multidev.sh
new file mode 100755
index 00000000..3048d61e
--- /dev/null
+++ b/checks/encrypted-multidev.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+
+blkdev="/dev/vdb"
+blkdev2="/dev/vdc"
+mnt=$(mktemp -d)
+pw=$(genpass)
+uuid=$(uuidgen)
+
+# link user and session keyrings so that the key can be found by the kernel
+keyctl link @u @s
+
+sfdisk "$blkdev" <<EOF
+label: gpt
+type=linux, size=1G
+type=linux, size=1G
+type=linux
+EOF
+
+udevadm settle
+
+echo "$pw" | bcachefs format \
+    --verbose \
+    --encrypted \
+    --replicas=2 \
+    --uuid "$uuid" \
+    --fs_label test-fs \
+    "${blkdev}"{1,2}
+
+udevadm settle
+
+echo "$pw" | bcachefs mount "UUID=$uuid" "$mnt"
+
+bcachefs device add "$mnt" "${blkdev}3"
+bcachefs device add "$mnt" "$blkdev2"
+
+udevadm settle
+
+blkid
+
+keyctl search @u user "bcachefs:$uuid"
+
+umount "$mnt"
+
+bcachefs mount "UUID=$uuid" "$mnt"
diff --git a/checks/encrypted-unlock.sh b/checks/encrypted-unlock.sh
new file mode 100755
index 00000000..23a345bb
--- /dev/null
+++ b/checks/encrypted-unlock.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+
+blkdev="/dev/vdb"
+mnt=$(mktemp -d)
+pw=$(genpass)
+uuid=$(uuidgen)
+
+# link user and session keyrings so that the key can be found by the kernel
+keyctl link @u @s
+
+echo "$pw" | bcachefs format \
+    --verbose \
+    --encrypted \
+    --uuid "$uuid" \
+    --fs_label test-fs \
+    "$blkdev"
+
+udevadm settle
+
+bcachefs unlock -c "$blkdev"
+
+echo "$pw" | bcachefs unlock "$blkdev"
+key_id=$(keyctl search @u user "bcachefs:$uuid")
+
+bcachefs mount "$blkdev" "$mnt"
+umount "$mnt"
+
+keyctl unlink "$key_id"
+
+echo "$pw" | bcachefs unlock -k session "$blkdev"
+key_id=$(keyctl search @s user "bcachefs:$uuid")
+
+mount -t bcachefs "$blkdev" "$mnt"
+umount "$mnt"
+
+keyctl unlink "$key_id"
+
+bcachefs mount -f <(echo "$pw") "$blkdev" "$mnt"
+key_id=$(keyctl search @u user "bcachefs:$uuid")
+umount "$mnt"
+keyctl unlink "$key_id"
+
+echo "$pw" | bcachefs mount -k stdin "$blkdev" "$mnt"
+key_id=$(keyctl search @u user "bcachefs:$uuid")
+umount "$mnt"
+keyctl unlink "$key_id"
+
+echo "$pw" | bcachefs mount "$blkdev" "$mnt"
+key_id=$(keyctl search @u user "bcachefs:$uuid")
+umount "$mnt"
+bcachefs mount -k fail "$blkdev"
+bcachefs mount -k wait "$blkdev" "$mnt"
+umount "$mnt"
+keyctl unlink "$key_id"
diff --git a/checks/nested.sh b/checks/nested.sh
new file mode 100755
index 00000000..edb955c1
--- /dev/null
+++ b/checks/nested.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+
+blkdev="/dev/vdb"
+mnt1=$(mktemp -d)
+mnt2=$(mktemp -d)
+pw=$(genpass)
+uuid=$(uuidgen)
+
+# link user and session keyrings so that the key can be found by the kernel
+keyctl link @u @s
+
+echo "$pw" | bcachefs format \
+    --verbose \
+    --encrypted \
+    --uuid "$uuid" \
+    --fs_label test-fs \
+    "$blkdev"
+
+udevadm settle
+
+echo "$pw" | bcachefs mount "UUID=$uuid" "$mnt1"
+
+fallocate --length 2G "$mnt1/fs.img"
+
+bcachefs format \
+    --verbose \
+    "$mnt1/fs.img"
+
+loopdev=$(losetup --find --show "$mnt1/fs.img")
+
+udevadm settle
+
+mount "$loopdev" "$mnt2"
+
+f3write "$mnt1"
+f3write "$mnt2"
+
+f3read "$mnt1"
+f3read "$mnt2"
diff --git a/checks/outputs.sh b/checks/outputs.sh
new file mode 100755
index 00000000..c4e55127
--- /dev/null
+++ b/checks/outputs.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+
+blkdev="/dev/vdb"
+mnt=$(mktemp -d)
+uuid=$(uuidgen)
+
+bcachefs format \
+    --verbose \
+    --uuid "$uuid" \
+    --fs_label test-fs \
+    "$blkdev"
+
+udevadm settle
+
+mount "$blkdev" "$mnt"
+
+bcachefs show-super "$blkdev" | grep -i "external.*$uuid"
+bcachefs fs usage "$mnt" | grep "$uuid"
diff --git a/checks/subvolume.sh b/checks/subvolume.sh
new file mode 100755
index 00000000..179554ef
--- /dev/null
+++ b/checks/subvolume.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+
+blkdev="/dev/vdb"
+mnt=$(mktemp -d)
+uuid=$(uuidgen)
+
+bcachefs format \
+    --verbose \
+    --uuid "$uuid" \
+    --fs_label test-fs \
+    "$blkdev"
+
+udevadm settle
+
+mount "$blkdev" "$mnt"
+
+touch "$mnt/file1"
+
+bcachefs subvolume create "$mnt/subvol1"
+bcachefs subvolume delete "$mnt/subvol1"
+
+(
+    cd "$mnt"
+
+    bcachefs subvolume create subvol1
+    bcachefs subvolume create subvol1/subvol1
+    bcachefs subvolume create subvol1/subvol2
+    touch subvol1/file1
+
+    rm subvol1/file1
+    bcachefs subvolume delete subvol1/subvol2
+    bcachefs subvolume delete subvol1/subvol1
+    bcachefs subvolume delete subvol1
+)
diff --git a/flake.nix b/flake.nix
index 4aa66a86..a3254ebd 100644
--- a/flake.nix
+++ b/flake.nix
@@ -59,6 +59,7 @@
         }:
         let
           inherit (builtins) readFile split;
+          inherit (lib) fileset;
           inherit (lib.lists) findFirst;
           inherit (lib.strings) hasPrefix removePrefix substring;
 
@@ -75,7 +76,17 @@
 
           commonArgs = {
             inherit version;
-            src = self;
+            src = fileset.toSource {
+              root = ./.;
+
+              fileset = fileset.difference (fileset.gitTracked ./.) (
+                fileset.unions [
+                  ./checks
+                  ./doc
+                  ./tests
+                ]
+              );
+            };
 
             env = {
               PKG_CONFIG_SYSTEMD_SYSTEMDSYSTEMUNITDIR = "${placeholder "out"}/lib/systemd/system";
@@ -150,31 +161,37 @@
             }
           );
 
-          checks.cargo-clippy = craneLib.cargoClippy (
-            commonArgs
-            // {
-              inherit cargoArtifacts;
-              cargoClippyExtraArgs = "--all-targets -- --deny warnings";
-            }
-          );
+          checks =
+            let
+              overlay = final: prev: { inherit (config.packages) bcachefs-tools; };
 
-          # we have to build our own `craneLib.cargoTest`
-          checks.cargo-test = craneLib.mkCargoDerivation (
-            commonArgs
-            // {
-              inherit cargoArtifacts;
-              doCheck = true;
+              cargo-clippy = craneLib.cargoClippy (
+                commonArgs
+                // {
+                  inherit cargoArtifacts;
+                  cargoClippyExtraArgs = "--all-targets -- --deny warnings";
+                }
+              );
 
-              enableParallelChecking = true;
+              # we have to build our own `craneLib.cargoTest`
+              cargo-test = craneLib.mkCargoDerivation (
+                commonArgs
+                // {
+                  inherit cargoArtifacts;
+                  doCheck = true;
 
-              pnameSuffix = "-test";
-              buildPhaseCargoCommand = "";
-              checkPhaseCargoCommand = ''
-                make ''${enableParallelChecking:+-j''${NIX_BUILD_CORES}} $makeFlags libbcachefs.a
-                cargo test --profile release -- --nocapture
-              '';
-            }
-          );
+                  enableParallelChecking = true;
+
+                  pnameSuffix = "-test";
+                  buildPhaseCargoCommand = "";
+                  checkPhaseCargoCommand = ''
+                    make ''${enableParallelChecking:+-j''${NIX_BUILD_CORES}} $makeFlags libbcachefs.a
+                    cargo test --profile release -- --nocapture
+                  '';
+                }
+              );
+            in
+            (import ./checks { pkgs = pkgs.extend overlay; }) // { inherit cargo-clippy cargo-test; };
 
           devShells.default = pkgs.mkShell {
             inputsFrom = [