From 7687ab5634351b2ad999a5acf014ae20addc9248 Mon Sep 17 00:00:00 2001 From: squat Date: Sun, 12 Apr 2026 21:06:18 +0200 Subject: [PATCH 1/2] linkding: init at v1.45.0 This commit introduces a new package: https://linkding.link, a popular and simple bookmark manager written in Django. The package has a fairly complex build-process and needs several patches to successfully build-run outside of a Docker container. I will be submitting PRs upstream for better support for customized runtime data directories to eliminate the need for lots of patching. Wherever possible, the derivation mirrors build steps from the upstream Dockerfiles. I have confirmed this package works exactly as expected when running: ```shell nix build .#linkding ./result/bin/linkding-bootstrap ./result/bin/linkding runserver ``` Signed-off-by: squat --- pkgs/by-name/li/linkding/package.nix | 301 +++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 pkgs/by-name/li/linkding/package.nix diff --git a/pkgs/by-name/li/linkding/package.nix b/pkgs/by-name/li/linkding/package.nix new file mode 100644 index 000000000000..920942e394b6 --- /dev/null +++ b/pkgs/by-name/li/linkding/package.nix @@ -0,0 +1,301 @@ +{ + lib, + buildNpmPackage, + fetchFromGitHub, + fetchurl, + gcc, + icu, + nix-update-script, + nixosTests, + pkg-config, + python3, + sqlite, + stdenv, + uwsgi, +}: +let + version = "1.45.0"; + + python = python3.override { + self = python; + packageOverrides = final: prev: { + django = prev.django_6; + }; + }; + + uwsgiWithPython = uwsgi.override { + plugins = [ "python3" ]; + python3 = python; + }; + + # Compile the SQLite ICU extension for case-insensitive search and ordering. + # This mirrors the compile-icu stage in the upstream Dockerfile. + icuExtension = stdenv.mkDerivation { + pname = "linkding-sqlite-icu"; + inherit version; + + src = fetchurl { + url = "https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60"; + name = "icu.c"; + hash = "sha256-DkELE5p82yZVz0GVFdWWAEU8eo10ob0fqx66Q7rxv+U="; + }; + + nativeBuildInputs = [ + gcc + pkg-config + ]; + + buildInputs = [ + icu.dev + sqlite.dev + ]; + + dontUnpack = true; + + buildPhase = '' + runHook preBuild + gcc -fPIC -shared $src \ + -I${sqlite.dev}/include \ + $(pkg-config --libs --cflags icu-uc icu-io) \ + -o libicu.so + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + install -Dm755 libicu.so $out/lib/libicu.so + runHook postInstall + ''; + }; +in +python.pkgs.buildPythonApplication (finalAttrs: { + pname = "linkding"; + inherit version; + pyproject = true; + + src = fetchFromGitHub { + owner = "sissbruecker"; + repo = "linkding"; + tag = "v${finalAttrs.version}"; + hash = "sha256-iGvUKmOPL0akfR52hzSGH6wu06/WP9ygiQ/HxsmrYWg="; + }; + + __structuredAttrs = true; + + build-system = with python.pkgs; [ + setuptools + ]; + + dependencies = with python.pkgs; [ + beautifulsoup4 + bleach + bleach-allowlist + django + djangorestframework + huey + markdown + mozilla-django-oidc + requests + waybackpy + ]; + + optional-dependencies = { + postgres = with python.pkgs; [ psycopg ]; + }; + + dontCheckRuntimeDeps = true; + # Django's runserver re-executes sys.argv[0] via the Python interpreter, + # so manage.py must remain a valid Python script and cannot be wrapped in bash. + dontWrapPythonPrograms = true; + + pyprojectAppendix = '' + [tool.setuptools.packages.find] + include = ["bookmarks*"] + [tool.setuptools.package-data] + bookmarks = ["static/**/*", "styles/**/*", "templates/**/*", "version.txt"] + ''; + + ui = buildNpmPackage { + inherit (finalAttrs) version; + + pname = "${finalAttrs.pname}-ui"; + src = finalAttrs.src; + + npmDepsHash = "sha256-zUMgl+h0BPm9QzGi1WZG8f0tDoYk8p+Al3q6uEKXqLk="; + + installPhase = '' + runHook preInstall + mkdir -p $out/bookmarks + mv bookmarks/static $out/bookmarks + runHook postInstall + ''; + }; + + postPatch = '' + echo "$pyprojectAppendix" >> pyproject.toml + + # Point the SQLite ICU extension to its store path so it is found + # regardless of the working directory at runtime. + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'SQLITE_ICU_EXTENSION_PATH = "./libicu.so"' \ + 'SQLITE_ICU_EXTENSION_PATH = "${icuExtension}/lib/libicu.so"' + + # Allow overriding the data directory via an internal environment variable + # so that the NixOS module can point it at the mutable state directory + # (/var/lib/linkding). The variable name is intentionally NixOS-specific + # and not a real linkding option to avoid confusing users. + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))' \ + 'BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))''\nDATA_DIR = os.getenv("_NIXOS_LINKDING_DATA_DIR", os.path.join(os.getcwd(), "data"))' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + '"filename": os.path.join(BASE_DIR, "data", "tasks.sqlite3"),' \ + '"filename": os.path.join(DATA_DIR, "tasks.sqlite3"),' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + '"NAME": os.path.join(BASE_DIR, "data", "db.sqlite3"),' \ + '"NAME": os.path.join(DATA_DIR, "db.sqlite3"),' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")' \ + 'LD_FAVICON_FOLDER = os.path.join(DATA_DIR, "favicons")' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_PREVIEW_FOLDER = os.path.join(BASE_DIR, "data", "previews")' \ + 'LD_PREVIEW_FOLDER = os.path.join(DATA_DIR, "previews")' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")' \ + 'LD_ASSET_FOLDER = os.path.join(DATA_DIR, "assets")' + + substituteInPlace bookmarks/utils.py \ + --replace-fail \ + 'import datetime' \ + 'import datetime''\nimport os' + + substituteInPlace bookmarks/utils.py \ + --replace-fail \ + 'with open("version.txt") as f:' \ + 'with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "version.txt")) as f:' + + substituteInPlace bookmarks/settings/prod.py \ + --replace-fail \ + 'with open(os.path.join(BASE_DIR, "data", "secretkey.txt")) as f:' \ + 'with open(os.path.join(DATA_DIR, "secretkey.txt")) as f:' + + substituteInPlace bookmarks/settings/dev.py \ + --replace-fail \ + 'os.path.join(BASE_DIR, "data", "favicons"),' \ + 'os.path.join(DATA_DIR, "favicons"),' + + substituteInPlace bookmarks/settings/dev.py \ + --replace-fail \ + 'os.path.join(BASE_DIR, "data", "previews"),' \ + 'os.path.join(DATA_DIR, "previews"),' + + # Patch management commands that bypass Django settings and use hardcoded + # relative "data/" paths. Replace them with paths derived from DATA_DIR + # via django.conf.settings so the data directory is always correct. + substituteInPlace bookmarks/management/commands/generate_secret_key.py \ + --replace-fail \ + 'from django.core.management.utils import get_random_secret_key' \ + 'from django.conf import settings''\nfrom django.core.management.utils import get_random_secret_key' + substituteInPlace bookmarks/management/commands/generate_secret_key.py \ + --replace-fail \ + 'secret_key_file = os.path.join("data", "secretkey.txt")' \ + 'secret_key_file = os.path.join(settings.DATA_DIR, "secretkey.txt")' + substituteInPlace bookmarks/management/commands/migrate_tasks.py \ + --replace-fail \ + 'import sqlite3' \ + 'import sqlite3''\nfrom django.conf import settings' + substituteInPlace bookmarks/management/commands/migrate_tasks.py \ + --replace-fail \ + 'db = sqlite3.connect(os.path.join("data", "db.sqlite3"))' \ + 'db = sqlite3.connect(os.path.join(settings.DATA_DIR, "db.sqlite3"))' + + # Place the version.txt file inside of the bookmarks package + # so that it can be installed by setuptools alongside the package + # in the Nix store. + mv version.txt bookmarks/version.txt + ''; + + preBuild = '' + cp -r ${finalAttrs.ui}/bookmarks/static/* bookmarks/static + ''; + + # Collect static files at build time so the result is a pure store path + # that can be served directly by the NixOS module without any runtime step. + # STATIC_ROOT in base.py defaults to $PWD/static, so the collected files + # land there and are picked up by postInstall. + postBuild = '' + mkdir data + ${python.interpreter} manage.py collectstatic --no-input + ''; + + postInstall = + let + pythonPath = python.pkgs.makePythonPath finalAttrs.passthru.dependencies; + in + '' + mkdir $out/bin + + cp -r static/* $out/${python.sitePackages}/bookmarks/static + + cp ./manage.py $out/bin/.manage.py + chmod +x $out/bin/.manage.py + + makeWrapper $out/bin/.manage.py $out/bin/linkding \ + --prefix PYTHONPATH : "${pythonPath}:$out/${python.sitePackages}" + + # Bootstrap script mirroring the upstream bootstrap.sh: creates data + # directories and runs Django management commands to initialize a fresh + # linkding installation. The linkding binary is referenced by its + # absolute store path so the script works without PATH manipulation. + cat > $out/bin/linkding-bootstrap << EOF + #!/bin/sh + DATA_DIR="\''${_NIXOS_LINKDING_DATA_DIR:-data}" + mkdir -p "\$DATA_DIR"/favicons "\$DATA_DIR"/previews "\$DATA_DIR"/assets + $out/bin/linkding generate_secret_key + $out/bin/linkding migrate + $out/bin/linkding enable_wal + $out/bin/linkding create_initial_superuser + $out/bin/linkding migrate_tasks + EOF + chmod +x $out/bin/linkding-bootstrap + ''; + + passthru = { + inherit + python + icuExtension + uwsgiWithPython + ; + tests = { + inherit (nixosTests) linkding linkding-postgres; + }; + updateScript = nix-update-script { + extraArgs = [ + "--subpackage" + "ui" + ]; + }; + }; + + meta = { + description = "Self-hosted bookmark manager designed to be minimal, fast, and easy to set up"; + homepage = "https://linkding.link/"; + changelog = "https://github.com/sissbruecker/linkding/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + squat + ]; + platforms = lib.platforms.linux; + }; +}) From 5c55071c5b8e570c057817731d41d959c120bec0 Mon Sep 17 00:00:00 2001 From: squat Date: Fri, 17 Apr 2026 13:39:05 +0200 Subject: [PATCH 2/2] nixos: introduce a service for linkding This commit adds a new NixOS service for the linkding package. This NixOS service runs 3 systemd services: 1. linkding-setup: this one-shot service bootstraps the creation of the runtime data directory and sets up the SQL database by running migrations, creating users, etc. 2. linkding: this is the main service, which runs linkding inside of uwsgi. 3. linkding-background-tasks: this is a sidecar service that runs `huey`, a task manager, which linkding uses to generate previews, downloads favicons, etc for your bookmarks. Wherever possible, the NixOS service mirrors the configuration from the upstream scripts/Docker compose. I've validated that following configurations work on my own machines: 1. linkding with the sqlite3: this is the simplest way to run the linkding service, storing all DB data in the data directory. Task runner is confirmed working and favicons/previews are correctly generated. 2. linkding with postgresql: this is a trickier configuration that needs to pull in an optional-dependency. This is also confirmed working even against a non-local postgres instance. Signed-off-by: squat --- .../manual/release-notes/rl-2605.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/linkding.nix | 403 ++++++++++++++++++ nixos/tests/all-tests.nix | 2 + nixos/tests/web-apps/linkding-postgres.nix | 42 ++ nixos/tests/web-apps/linkding.nix | 38 ++ 6 files changed, 488 insertions(+) create mode 100644 nixos/modules/services/web-apps/linkding.nix create mode 100644 nixos/tests/web-apps/linkding-postgres.nix create mode 100644 nixos/tests/web-apps/linkding.nix diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 1b931222d9cc..deb7c9c85041 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -98,6 +98,8 @@ - [Ente Auth](https://ente.io/auth/), an open source 2FA authenticator, with end-to-end encrypted backups. Available as [programs.ente-auth](#opt-programs.ente-auth.enable). +- [linkding](https://linkding.link/), a self-hosted bookmark manager designed to be minimal, fast, and easy to set up. Available as [services.linkding](#opt-services.linkding.enable). + - [Tinyauth](https://tinyauth.app/), a simple authentication middleware for web apps, with OAuth and LDAP support. Available as [services.tinyauth](#opt-services.tinyauth.enable). - [Dawarich](https://dawarich.app/), a self-hostable location history tracker. Available as [services.dawarich](#opt-services.dawarich.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index b23517fcce16..ec5c517e3dd4 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1697,6 +1697,7 @@ ./services/web-apps/librespeed.nix ./services/web-apps/libretranslate.nix ./services/web-apps/limesurvey.nix + ./services/web-apps/linkding.nix ./services/web-apps/linkwarden.nix ./services/web-apps/lubelogger.nix ./services/web-apps/mainsail.nix diff --git a/nixos/modules/services/web-apps/linkding.nix b/nixos/modules/services/web-apps/linkding.nix new file mode 100644 index 000000000000..fae7fb89c4d9 --- /dev/null +++ b/nixos/modules/services/web-apps/linkding.nix @@ -0,0 +1,403 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkEnableOption + mkIf + mkOption + mkPackageOption + optionalAttrs + types + ; + + cfg = config.services.linkding; +in +{ + options.services.linkding = { + enable = mkEnableOption "linkding, a self-hosted bookmark manager"; + + package = mkPackageOption pkgs "linkding" { }; + + user = mkOption { + type = types.str; + default = "linkding"; + description = '' + User account under which linkding runs. + + ::: {.note} + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for ensuring the + user exists before the linkding service starts. + ::: + ''; + }; + + group = mkOption { + type = types.str; + default = "linkding"; + description = '' + Group under which linkding runs. + + ::: {.note} + If left as the default value this group will automatically be created + on system activation, otherwise you are responsible for ensuring the + group exists before the linkding service starts. + ::: + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/linkding"; + description = "Directory used for all mutable state: SQLite database, secret key, favicons, previews, and assets."; + }; + + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address on which linkding listens."; + }; + + port = mkOption { + type = types.port; + default = 9090; + description = "Port on which linkding listens."; + }; + + contextPath = mkOption { + type = types.str; + default = ""; + example = "linkding/"; + description = '' + Configures a URL context path under which linkding is accessible. + When set, linkding is available at `http://host:/`. + Must end with a `/` when non-empty. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/linkding.env"; + description = '' + Path to an environment file loaded by all linkding services. + Useful for injecting secrets that should not appear in the Nix store, + such as `LD_DB_PASSWORD` or `LD_SUPERUSER_PASSWORD`. + ''; + }; + + settings = mkOption { + type = types.attrsOf types.str; + default = { }; + example = { + LD_DISABLE_BACKGROUND_TASKS = "True"; + LD_DISABLE_URL_VALIDATION = "True"; + LD_ENABLE_OIDC = "True"; + }; + description = '' + Additional environment variables passed to linkding. + Refer to the [linkding documentation](https://linkding.link/options/) + for the full list of supported `LD_*` options. + ''; + }; + + database = { + type = mkOption { + type = types.enum [ + "sqlite" + "postgres" + ]; + default = "sqlite"; + description = "Database engine to use. Defaults to SQLite."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "PostgreSQL server host."; + }; + + port = mkOption { + type = types.port; + default = 5432; + description = "PostgreSQL server port."; + }; + + name = mkOption { + type = types.str; + default = "linkding"; + description = "PostgreSQL database name."; + }; + + user = mkOption { + type = types.str; + default = "linkding"; + description = "PostgreSQL user name."; + }; + + createLocally = mkOption { + type = types.bool; + default = false; + description = "Whether to automatically create a local PostgreSQL database and user."; + }; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the linkding port in the firewall."; + }; + }; + + config = mkIf cfg.enable ( + let + pkg = cfg.package; + + usePostgres = cfg.database.type == "postgres"; + + pythonPath = + "${pkg.passthru.python.pkgs.makePythonPath pkg.passthru.dependencies}:${lib.getBin pkg}/${pkg.passthru.python.sitePackages}" + + lib.strings.optionalString usePostgres ":${pkg.passthru.python.pkgs.makePythonPath pkg.optional-dependencies.postgres}"; + + # Build the environment passed to every linkding process. + environment = { + DJANGO_SETTINGS_MODULE = "bookmarks.settings.prod"; + _NIXOS_LINKDING_DATA_DIR = cfg.dataDir; + LD_SERVER_PORT = toString cfg.port; + } + // optionalAttrs (cfg.contextPath != "") { + LD_CONTEXT_PATH = cfg.contextPath; + } + // optionalAttrs usePostgres { + LD_DB_ENGINE = "postgres"; + LD_DB_DATABASE = cfg.database.name; + LD_DB_USER = cfg.database.user; + LD_DB_HOST = "/run/postgresql"; + } + // optionalAttrs (usePostgres && !cfg.database.createLocally) { + LD_DB_HOST = cfg.database.host; + LD_DB_PORT = toString cfg.database.port; + } + // cfg.settings; + + environmentFile = pkgs.writeText "linkding-environment" (lib.generators.toKeyValue { } environment); + + # Generate a uwsgi.ini for the linkding instance, adapted for NixOS from + # the upstream uwsgi.ini. The static-map entries serve pre-generated + # static files from the Nix store as well as the mutable user-data + # directories (favicons, previews) from the data directory. + uwsgiIni = pkgs.writeText "linkding-uwsgi.ini" '' + [uwsgi] + plugins-dir = ${pkg.passthru.uwsgiWithPython}/lib/uwsgi + plugin = python3 + module = bookmarks.wsgi:application + env = DJANGO_SETTINGS_MODULE=bookmarks.settings.prod + processes = 2 + threads = 2 + buffer-size = 8192 + die-on-term = true + mime-file = ${pkgs.mailcap}/etc/mime.types + http = ${cfg.address}:${toString cfg.port} + static-map = /${cfg.contextPath}static=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static + static-map = /${cfg.contextPath}static=${cfg.dataDir}/favicons + static-map = /${cfg.contextPath}static=${cfg.dataDir}/previews + static-map = /${cfg.contextPath}robots.txt=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static/robots.txt + + if-env = LD_REQUEST_TIMEOUT + http-timeout = %(_) + socket-timeout = %(_) + harakiri = %(_) + endif = + + if-env = LD_REQUEST_MAX_CONTENT_LENGTH + limit-post = %(_) + endif = + + if-env = LD_LOG_X_FORWARDED_FOR + log-x-forwarded-for = %(_) + endif = + + if-env = LD_DISABLE_REQUEST_LOGS=true + disable-logging = true + log-4xx = true + log-5xx = true + endif = + ''; + + # Manage wrapper script installed into the system PATH so administrators can + # run Django management commands as the linkding service user. + linkdingManageScript = + let + args = lib.escapeShellArgs ( + [ + "--uid=${cfg.user}" + "--gid=${cfg.group}" + "--working-directory=${cfg.dataDir}" + "--property=EnvironmentFile=${environmentFile}" + ] + ++ lib.optional (cfg.environmentFile != null) "--property=EnvironmentFile=${cfg.environmentFile}" + ++ [ + "--property=ReadWritePaths=${cfg.dataDir}" + "--setenv=PYTHONPATH=${pythonPath}" + "--pty" + "--wait" + "--collect" + "--service-type=exec" + "--quiet" + "--" + "${lib.getExe' pkg "linkding"}" + ] + ); + in + pkgs.writeShellScriptBin "linkding-manage" '' + exec ${lib.getExe' config.systemd.package "systemd-run"} ${args} "$@" + ''; + + commonServiceConfig = { + Slice = "system-linkding.slice"; + User = cfg.user; + Group = cfg.group; + EnvironmentFile = [ + environmentFile + ] + ++ lib.optional (cfg.environmentFile != null) cfg.environmentFile; + Environment = "PYTHONPATH=${pythonPath}"; + WorkingDirectory = cfg.dataDir; + StateDirectory = [ + "linkding" + "linkding/favicons" + "linkding/previews" + "linkding/assets" + ]; + StateDirectoryMode = "0750"; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ cfg.dataDir ]; + PrivateMounts = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RemoveIPC = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + }; + in + { + assertions = [ + { + assertion = cfg.database.createLocally -> usePostgres; + message = "services.linkding.database.createLocally requires services.linkding.database.type = \"postgres\""; + } + { + assertion = + cfg.database.createLocally -> cfg.database.host == "localhost" || cfg.database.host == ""; + message = "services.linkding.database.host should be empty or \"localhost\" when createLocally is enabled"; + } + { + assertion = + cfg.database.createLocally + -> cfg.database.user == cfg.user && cfg.database.user == cfg.database.name; + message = "services.linkding.database.user must match services.linkding.user and services.linkding.database.name when createLocally is enabled"; + } + { + assertion = cfg.contextPath == "" || lib.hasSuffix "/" cfg.contextPath; + message = "services.linkding.contextPath must end with \"/\" when non-empty"; + } + ]; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + + environment.systemPackages = [ linkdingManageScript ]; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + users.groups.${cfg.group} = { }; + + systemd.slices.system-linkding = { + description = "linkding bookmark manager System Slice"; + documentation = [ "https://linkding.link/" ]; + }; + + # One-shot setup service: run database migrations and first-time + # initialization steps taken from the upstream bootstrap.sh. + systemd.services.linkding-setup = { + description = "linkding database migrations and initialization"; + after = [ + "network.target" + ] + ++ lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ]; + requires = lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ]; + + serviceConfig = commonServiceConfig // { + Type = "oneshot"; + ExecStart = "${lib.getExe' pkg "linkding-bootstrap"}"; + }; + }; + + # Main WSGI service — starts after setup completes. + systemd.services.linkding = { + description = "linkding bookmark manager"; + wantedBy = [ "multi-user.target" ]; + after = [ "linkding-setup.service" ]; + requires = [ "linkding-setup.service" ]; + startLimitBurst = 5; + startLimitIntervalSec = 60; + serviceConfig = commonServiceConfig // { + Type = "exec"; + ExecStart = "${lib.getExe pkgs.uwsgi} --ini ${uwsgiIni}"; + Restart = "on-failure"; + }; + }; + + # Background task processor (Huey). Can be disabled via + # services.linkding.settings.LD_DISABLE_BACKGROUND_TASKS = "True". + systemd.services.linkding-background-tasks = + mkIf ((cfg.settings.LD_DISABLE_BACKGROUND_TASKS or "False") != "True") + { + description = "linkding background task processor"; + wantedBy = [ "multi-user.target" ]; + after = [ "linkding-setup.service" ]; + requires = [ "linkding-setup.service" ]; + + serviceConfig = commonServiceConfig // { + Type = "exec"; + ExecStart = "${lib.getExe' pkg "linkding"} run_huey -f"; + Restart = "on-failure"; + RestartSec = "5s"; + }; + }; + + # Automatically provision a local PostgreSQL database when requested. + services.postgresql = mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + }; + } + ); + + meta.maintainers = with lib.maintainers; [ squat ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 929e8362e8f3..61da880f2b03 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -889,6 +889,8 @@ in lighttpd = runTest ./lighttpd.nix; limesurvey = runTest ./limesurvey.nix; limine = import ./limine { inherit runTest; }; + linkding = runTest ./web-apps/linkding.nix; + linkding-postgres = runTest ./web-apps/linkding-postgres.nix; linkwarden = runTest ./web-apps/linkwarden.nix; listmonk = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./listmonk.nix { }; litellm = runTest ./litellm.nix; diff --git a/nixos/tests/web-apps/linkding-postgres.nix b/nixos/tests/web-apps/linkding-postgres.nix new file mode 100644 index 000000000000..486e8de653b3 --- /dev/null +++ b/nixos/tests/web-apps/linkding-postgres.nix @@ -0,0 +1,42 @@ +{ lib, ... }: +{ + name = "linkding-postgres"; + + meta = { + maintainers = with lib.maintainers; [ squat ]; + }; + + nodes.machine = + { ... }: + { + services.linkding = { + enable = true; + port = 9090; + database = { + createLocally = true; + type = "postgres"; + }; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("linkding.service") + machine.wait_for_open_port(9090) + + with subtest("Login page loads"): + machine.succeed( + "curl -sSfL http://127.0.0.1:9090 | grep -i 'linkding'" + ) + + with subtest("Health endpoint responds"): + machine.succeed( + "curl -sSf http://127.0.0.1:9090/health" + ) + + with subtest("linkding-manage works"): + machine.succeed( + "linkding-manage version" + ) + ''; +} diff --git a/nixos/tests/web-apps/linkding.nix b/nixos/tests/web-apps/linkding.nix new file mode 100644 index 000000000000..074e5c947a33 --- /dev/null +++ b/nixos/tests/web-apps/linkding.nix @@ -0,0 +1,38 @@ +{ lib, pkgs, ... }: +{ + name = "linkding"; + + meta = { + maintainers = with lib.maintainers; [ squat ]; + }; + + nodes.machine = + { ... }: + { + services.linkding = { + enable = true; + port = 9090; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("linkding.service") + machine.wait_for_open_port(9090) + + with subtest("Login page loads"): + machine.succeed( + "curl -sSfL http://127.0.0.1:9090 | grep -i 'linkding'" + ) + + with subtest("Health endpoint responds"): + machine.succeed( + "curl -sSf http://127.0.0.1:9090/health" + ) + + with subtest("linkding-manage works"): + machine.succeed( + "linkding-manage version" + ) + ''; +}