diff --git a/README.md b/README.md
index 200f9d1..b6d6e85 100644
--- a/README.md
+++ b/README.md
@@ -232,6 +232,40 @@ npm start
+
+Nix
+
+**Flake** (NixOS or flake-enabled systems):
+
+```nix
+{
+ inputs.troutbot.url = "github:notashelf/troutbot";
+
+ outputs = { self, nixpkgs, troutbot }: {
+ nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
+ modules = [
+ troutbot.nixosModules.troutbot
+ {
+ services.troutbot = {
+ enable = true;
+ environmentFile = "/path/to/.env"; # use Agenix if possible
+ configPath = "/path/to/config.ts" # use Agenix if possible
+ };
+ }
+ ];
+ };
+ };
+};
+```
+
+**Run directly**:
+
+```bash
+nix run github:notashelf/troutbot
+```
+
+
+
Docker
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..6f6bfa6
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1769461804,
+ "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..ef20b2b
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,36 @@
+{
+ description = "Troutbot - GitHub webhook bot";
+
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
+
+ outputs = {
+ self,
+ nixpkgs,
+ }: let
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ ];
+
+ forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
+ in {
+ nixosModules = {
+ troutbot = import ./nix/modules/nixos.nix self;
+ default = self.nixosModules.troutbot;
+ };
+
+ packages = forEachSystem (pkgs: {
+ troutbot = pkgs.callPackage ./nix/package.nix {};
+ default = self.packages.${pkgs.hostPlatform.system}.troutbot;
+ });
+
+ devShells = forEachSystem (pkgs: {
+ default = pkgs.mkShell {
+ name = "troutbot-dev";
+ packages = [pkgs.nodejs-slim pkgs.pnpm];
+ };
+ });
+ };
+}
diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix
new file mode 100644
index 0000000..b9502c7
--- /dev/null
+++ b/nix/modules/nixos.nix
@@ -0,0 +1,83 @@
+self: {
+ config,
+ pkgs,
+ lib,
+ ...
+}: let
+ inherit (lib.modules) mkIf;
+ inherit (lib.options) mkOption mkEnableOption literalExpression;
+ inherit (lib.types) nullOr str port package;
+
+ defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.troutbot;
+ cfg = config.services.troutbot;
+in {
+ options.services.troutbot = {
+ enable = mkEnableOption "troutbot";
+
+ package = mkOption {
+ type = nullOr package;
+ default = defaultPackage;
+ defaultText = literalExpression "inputs.troutbot.packages.${pkgs.stdenv.hostPlatform.system}.troutbot";
+ description = ''
+ The Troutbot package to use.
+
+ By default, this option will use the `packages.default` as exposed by this flake.
+ '';
+ };
+
+ user = mkOption {
+ type = str;
+ default = "troutbot";
+ };
+
+ group = mkOption {
+ type = str;
+ default = "troutbot";
+ };
+
+ port = mkOption {
+ type = port;
+ default = 3000;
+ };
+
+ environmentFile = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ configPath = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ users.users.${cfg.user} = {
+ isSystemUser = true;
+ group = cfg.group;
+ };
+
+ users.groups.${cfg.group} = {};
+
+ systemd.services.troutbot = {
+ description = "Troutbot";
+ after = ["network.target"];
+ wantedBy = ["multi-user.target"];
+ serviceConfig = {
+ Type = "simple";
+ User = cfg.user;
+ Group = cfg.group;
+ ExecStart = "${lib.getExe cfg.package}";
+ Restart = "on-failure";
+ EnvironmentFile = cfg.environmentFile;
+ NODE_ENV = "production";
+ CONFIG_PATH = cfg.configPath;
+ PORT = toString cfg.port;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ PrivateTmp = true;
+ NoNewPrivileges = true;
+ };
+ };
+ };
+}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 0000000..5557d37
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,71 @@
+{
+ lib,
+ stdenv,
+ nodejs,
+ pnpmConfigHook,
+ fetchPnpmDeps,
+ pnpm,
+ makeBinaryWrapper,
+}:
+stdenv.mkDerivation (finalAttrs: {
+ pname = "troutbot";
+ version = "0-unstable-2026-01-30";
+
+ src = lib.fileset.toSource {
+ root = ../.;
+ fileset = lib.fileset.unions [
+ ../src
+ ../config.example.ts
+ ../package.json
+ ../pnpm-lock.yaml
+ ../tsconfig.json
+ ];
+ };
+
+ strictDeps = true;
+ nativeBuildInputs = [
+ nodejs # in case scripts are run outside of a pnpm call
+ pnpmConfigHook
+ pnpm # at least required by pnpmConfigHook, if not other (custom) phases
+
+ makeBinaryWrapper
+ ];
+
+ pnpmDeps = fetchPnpmDeps {
+ inherit (finalAttrs) pname version src;
+ fetcherVersion = 3;
+ hash = "sha256-y8LV1D+EgGcZ79lmxS20dqYBPEfk4atma+RWf7pJI30=";
+ };
+
+ buildPhase = ''
+ runHook preBuild
+
+ pnpm run build --outDir dist
+
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+
+ mkdir -p $out/{bin,share}
+
+ # Copy transpiled result
+ cp -rv dist/* $out/share
+
+ # Copy the example config
+ install -Dm755 config.example.ts $out/share
+
+ makeWrapper ${lib.getExe nodejs} $out/bin/troutbot \
+ --set-default NODE_ENV production \
+ --add-flags "$out/share/index.js"
+
+ runHook postInstall
+ '';
+
+ meta = {
+ description = "The ultimate trout population helper";
+ license = lib.licenses.eupl12;
+ maintainers = with lib.maintainers; [NotAShelf];
+ };
+})