umami: init at 2.19.0; nixos/umami: init (#380249)

This commit is contained in:
Niklas Hambüchen 2025-07-29 16:48:52 +02:00 committed by GitHub
commit 436a8a1152
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 623 additions and 0 deletions

View File

@ -23,6 +23,8 @@
- [Fediwall](https://fediwall.social), a web application for live displaying toots from mastodon, inspired by mastowall. Available as [services.fediwall](#opt-services.fediwall.enable).
- [umami](https://github.com/umami-software/umami), a simple, fast, privacy-focused alternative to Google Analytics. Available with [services.umami](#opt-services.umami.enable).
- [FileBrowser](https://filebrowser.org/), a web application for managing and sharing files. Available as [services.filebrowser](#opt-services.filebrowser.enable).
- Options under [networking.getaddrinfo](#opt-networking.getaddrinfo.enable) are now allowed to declaratively configure address selection and sorting behavior of `getaddrinfo` in dual-stack networks.

View File

@ -1690,6 +1690,7 @@
./services/web-apps/szurubooru.nix
./services/web-apps/trilium.nix
./services/web-apps/tt-rss.nix
./services/web-apps/umami.nix
./services/web-apps/vikunja.nix
./services/web-apps/wakapi.nix
./services/web-apps/weblate.nix

View File

@ -0,0 +1,316 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
concatStringsSep
filterAttrs
getExe
hasPrefix
hasSuffix
isString
literalExpression
maintainers
mapAttrs
mkEnableOption
mkIf
mkOption
mkPackageOption
optional
optionalString
types
;
cfg = config.services.umami;
nonFileSettings = filterAttrs (k: _: !hasSuffix "_FILE" k) cfg.settings;
in
{
options.services.umami = {
enable = mkEnableOption "umami";
package = mkPackageOption pkgs "umami" { } // {
apply =
pkg:
pkg.override {
databaseType = cfg.settings.DATABASE_TYPE;
collectApiEndpoint = optionalString (
cfg.settings.COLLECT_API_ENDPOINT != null
) cfg.settings.COLLECT_API_ENDPOINT;
trackerScriptNames = cfg.settings.TRACKER_SCRIPT_NAME;
basePath = cfg.settings.BASE_PATH;
};
};
createPostgresqlDatabase = mkOption {
type = types.bool;
default = true;
example = false;
description = ''
Whether to automatically create the database for Umami using PostgreSQL.
Both the database name and username will be `umami`, and the connection is
made through unix sockets using peer authentication.
'';
};
settings = mkOption {
description = ''
Additional configuration (environment variables) for Umami, see
<https://umami.is/docs/environment-variables> for supported values.
'';
type = types.submodule {
freeformType =
with types;
attrsOf (oneOf [
bool
int
str
]);
options = {
APP_SECRET_FILE = mkOption {
type = types.nullOr (
types.str
// {
# We don't want users to be able to pass a path literal here but
# it should look like a path.
check = it: isString it && types.path.check it;
}
);
default = null;
example = "/run/secrets/umamiAppSecret";
description = ''
A file containing a secure random string. This is used for signing user sessions.
The contents of the file are read through systemd credentials, therefore the
user running umami does not need permissions to read the file.
If you wish to set this to a string instead (not recommended since it will be
placed world-readable in the Nix store), you can use the APP_SECRET option.
'';
};
DATABASE_URL = mkOption {
type = types.nullOr (
types.str
// {
check =
it:
isString it
&& ((hasPrefix "postgresql://" it) || (hasPrefix "postgres://" it) || (hasPrefix "mysql://" it));
}
);
# For some reason, Prisma requires the username in the connection string
# and can't derive it from the current user.
default =
if cfg.createPostgresqlDatabase then
"postgresql://umami@localhost/umami?host=/run/postgresql"
else
null;
defaultText = literalExpression ''if config.services.umami.createPostgresqlDatabase then "postgresql://umami@localhost/umami?host=/run/postgresql" else null'';
example = "postgresql://root:root@localhost/umami";
description = ''
Connection string for the database. Must start with `postgresql://`, `postgres://`
or `mysql://`.
'';
};
DATABASE_URL_FILE = mkOption {
type = types.nullOr (
types.str
// {
# We don't want users to be able to pass a path literal here but
# it should look like a path.
check = it: isString it && types.path.check it;
}
);
default = null;
example = "/run/secrets/umamiDatabaseUrl";
description = ''
A file containing a connection string for the database. The connection string
must start with `postgresql://`, `postgres://` or `mysql://`.
If using this, then DATABASE_TYPE must be set to the appropriate value.
The contents of the file are read through systemd credentials, therefore the
user running umami does not need permissions to read the file.
'';
};
DATABASE_TYPE = mkOption {
type = types.nullOr (
types.enum [
"postgresql"
"mysql"
]
);
default =
if cfg.settings.DATABASE_URL != null && hasPrefix "mysql://" cfg.settings.DATABASE_URL then
"mysql"
else
"postgresql";
defaultText = literalExpression ''if config.services.umami.settings.DATABASE_URL != null && hasPrefix "mysql://" config.services.umami.settings.DATABASE_URL then "mysql" else "postgresql"'';
example = "mysql";
description = ''
The type of database to use. This is automatically inferred from DATABASE_URL, but
must be set manually if you are using DATABASE_URL_FILE.
'';
};
COLLECT_API_ENDPOINT = mkOption {
type = types.nullOr types.str;
default = null;
example = "/api/alternate-send";
description = ''
Allows you to send metrics to a location different than the default `/api/send`.
'';
};
TRACKER_SCRIPT_NAME = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "tracker.js" ];
description = ''
Allows you to assign a custom name to the tracker script different from the default `script.js`.
'';
};
BASE_PATH = mkOption {
type = types.str;
default = "";
example = "/analytics";
description = ''
Allows you to host Umami under a subdirectory.
You may need to update your reverse proxy settings to correctly handle the BASE_PATH prefix.
'';
};
DISABLE_UPDATES = mkOption {
type = types.bool;
default = true;
example = false;
description = ''
Disables the check for new versions of Umami.
'';
};
DISABLE_TELEMETRY = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Umami collects completely anonymous telemetry data in order help improve the application.
You can choose to disable this if you don't want to participate.
'';
};
HOSTNAME = mkOption {
type = types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
The address to listen on.
'';
};
PORT = mkOption {
type = types.port;
default = 3000;
example = 3010;
description = ''
The port to listen on.
'';
};
};
};
default = { };
example = {
APP_SECRET_FILE = "/run/secrets/umamiAppSecret";
DISABLE_TELEMETRY = true;
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = (cfg.settings.APP_SECRET_FILE != null) != (cfg.settings ? APP_SECRET);
message = "One (and only one) of services.umami.settings.APP_SECRET_FILE and services.umami.settings.APP_SECRET must be set.";
}
{
assertion = (cfg.settings.DATABASE_URL_FILE != null) != (cfg.settings.DATABASE_URL != null);
message = "One (and only one) of services.umami.settings.DATABASE_URL_FILE and services.umami.settings.DATABASE_URL must be set.";
}
{
assertion =
cfg.createPostgresqlDatabase
-> cfg.settings.DATABASE_URL == "postgresql://umami@localhost/umami?host=/run/postgresql";
message = "The option config.services.umami.createPostgresqlDatabase is enabled, but config.services.umami.settings.DATABASE_URL has been modified.";
}
];
services.postgresql = mkIf cfg.createPostgresqlDatabase {
enable = true;
ensureDatabases = [ "umami" ];
ensureUsers = [
{
name = "umami";
ensureDBOwnership = true;
ensureClauses.login = true;
}
];
};
systemd.services.umami = {
environment = mapAttrs (_: toString) nonFileSettings;
description = "Umami: a simple, fast, privacy-focused alternative to Google Analytics";
after = [ "network.target" ] ++ (optional (cfg.createPostgresqlDatabase) "postgresql.service");
wantedBy = [ "multi-user.target" ];
script =
let
loadCredentials =
(optional (
cfg.settings.APP_SECRET_FILE != null
) ''export APP_SECRET="$(systemd-creds cat appSecret)"'')
++ (optional (
cfg.settings.DATABASE_URL_FILE != null
) ''export DATABASE_URL="$(systemd-creds cat databaseUrl)"'');
in
''
${concatStringsSep "\n" loadCredentials}
${getExe cfg.package}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 3;
DynamicUser = true;
LoadCredential =
(optional (cfg.settings.APP_SECRET_FILE != null) "appSecret:${cfg.settings.APP_SECRET_FILE}")
++ (optional (
cfg.settings.DATABASE_URL_FILE != null
) "databaseUrl:${cfg.settings.DATABASE_URL_FILE}");
# Hardening
CapabilityBoundingSet = "";
NoNewPrivileges = true;
PrivateUsers = true;
PrivateTmp = true;
PrivateDevices = true;
PrivateMounts = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = (optional cfg.createPostgresqlDatabase "AF_UNIX") ++ [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
};
};
meta.maintainers = with maintainers; [ diogotcorreia ];
}

View File

@ -1509,6 +1509,7 @@ in
ucarp = runTest ./ucarp.nix;
udisks2 = runTest ./udisks2.nix;
ulogd = runTest ./ulogd/ulogd.nix;
umami = runTest ./web-apps/umami.nix;
umurmur = runTest ./umurmur.nix;
unbound = runTest ./unbound.nix;
unifi = runTest ./unifi.nix;

View File

@ -0,0 +1,45 @@
{ lib, ... }:
{
name = "umami-nixos";
meta.maintainers = with lib.maintainers; [ diogotcorreia ];
nodes.machine =
{ pkgs, ... }:
{
services.umami = {
enable = true;
settings = {
APP_SECRET = "very_secret";
};
};
};
testScript = ''
import json
machine.wait_for_unit("umami.service")
machine.wait_for_open_port(3000)
machine.succeed("curl --fail http://localhost:3000/")
machine.succeed("curl --fail http://localhost:3000/script.js")
res = machine.succeed("""
curl -f --json '{ "username": "admin", "password": "umami" }' http://localhost:3000/api/auth/login
""")
token = json.loads(res)['token']
res = machine.succeed("""
curl -f -H 'Authorization: Bearer %s' --json '{ "domain": "localhost", "name": "Test" }' http://localhost:3000/api/websites
""" % token)
print(res)
websiteId = json.loads(res)['id']
res = machine.succeed("""
curl -f -H 'Authorization: Bearer %s' http://localhost:3000/api/websites/%s
""" % (token, websiteId))
website = json.loads(res)
assert website["name"] == "Test"
assert website["domain"] == "localhost"
'';
}

View File

@ -0,0 +1,201 @@
{
lib,
stdenvNoCC,
fetchFromGitHub,
fetchurl,
makeWrapper,
nixosTests,
nodejs,
pnpm_10,
prisma-engines,
openssl,
rustPlatform,
# build variables
databaseType ? "postgresql",
collectApiEndpoint ? "",
trackerScriptNames ? [ ],
basePath ? "",
}:
let
sources = lib.importJSON ./sources.json;
pnpm = pnpm_10;
geocities = stdenvNoCC.mkDerivation {
pname = "umami-geocities";
version = sources.geocities.date;
src = fetchurl {
url = "https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/${sources.geocities.rev}/redist/GeoLite2-City.tar.gz";
inherit (sources.geocities) hash;
};
doBuild = false;
installPhase = ''
mkdir -p $out
cp ./GeoLite2-City.mmdb $out/GeoLite2-City.mmdb
'';
meta.license = lib.licenses.cc-by-40;
};
# Pin the specific version of prisma to the one used by upstream
# to guarantee compatibility.
prisma-engines' = prisma-engines.overrideAttrs (old: rec {
version = "6.7.0";
src = fetchFromGitHub {
owner = "prisma";
repo = "prisma-engines";
tag = version;
hash = "sha256-Ty8BqWjZluU6a5xhSAVb2VoTVY91UUj6zoVXMKeLO4o=";
};
cargoHash = "sha256-HjDoWa/JE6izUd+hmWVI1Yy3cTBlMcvD9ANsvqAoHBI=";
cargoDeps = rustPlatform.fetchCargoVendor {
inherit (old) pname;
inherit src version;
hash = cargoHash;
};
});
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "umami";
version = "2.19.0";
nativeBuildInputs = [
makeWrapper
nodejs
pnpm.configHook
];
src = fetchFromGitHub {
owner = "umami-software";
repo = "umami";
tag = "v${finalAttrs.version}";
hash = "sha256-luiwGmCujbFGWANSCOiHIov56gsMQ6M+Bj0stcz9he8=";
};
# install dev dependencies as well, for rollup
pnpmInstallFlags = [ "--prod=false" ];
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs)
pname
pnpmInstallFlags
version
src
;
fetcherVersion = 2;
hash = "sha256-2GiCeCt/mU5Dm5YHQgJF3127WPHq5QLX8JRcUv6B6lE=";
};
env.CYPRESS_INSTALL_BINARY = "0";
env.NODE_ENV = "production";
env.NEXT_TELEMETRY_DISABLED = "1";
# copy-db-files uses this variable to decide which Prisma schema to use
env.DATABASE_TYPE = databaseType;
env.COLLECT_API_ENDPOINT = collectApiEndpoint;
env.TRACKER_SCRIPT_NAME = lib.concatStringsSep "," trackerScriptNames;
env.BASE_PATH = basePath;
# Allow prisma-cli to find prisma-engines without having to download them
env.PRISMA_QUERY_ENGINE_LIBRARY = "${prisma-engines'}/lib/libquery_engine.node";
env.PRISMA_SCHEMA_ENGINE_BINARY = "${prisma-engines'}/bin/schema-engine";
buildPhase = ''
runHook preBuild
pnpm copy-db-files
pnpm build-db-client # prisma generate
pnpm build-tracker
pnpm build-app
runHook postBuild
'';
checkPhase = ''
runHook preCheck
pnpm test
runHook postCheck
'';
doCheck = true;
installPhase = ''
runHook preInstall
mv .next/standalone $out
mv .next/static $out/.next/static
# Include prisma cli in next standalone build.
# This is preferred to using the prisma in nixpkgs because it guarantees
# the version matches.
# See https://nextjs-forum.com/post/1280550687998083198
# and https://nextjs.org/docs/pages/api-reference/config/next-config-js/output#caveats
# Unfortunately, using outputFileTracingIncludes doesn't work because of pnpm's symlink structure,
# so we just copy the files manually.
mkdir -p $out/node_modules/.bin
cp node_modules/.bin/prisma $out/node_modules/.bin
cp -a node_modules/prisma $out/node_modules
cp -a node_modules/.pnpm/@prisma* $out/node_modules/.pnpm
cp -a node_modules/.pnpm/prisma* $out/node_modules/.pnpm
# remove broken symlinks (some dependencies that are not relevant for running migrations)
find "$out"/node_modules/.pnpm/@prisma* -xtype l -exec rm {} \;
find "$out"/node_modules/.pnpm/prisma* -xtype l -exec rm {} \;
cp -R public $out/public
cp -R prisma $out/prisma
ln -s ${geocities} $out/geo
mkdir -p $out/bin
# Run database migrations before starting umami.
# Add openssl to PATH since it is required for prisma to make SSL connections.
# Force working directory to $out because umami assumes many paths are relative to it (e.g., prisma and geolite).
makeWrapper ${nodejs}/bin/node $out/bin/umami-server \
--set NODE_ENV production \
--set NEXT_TELEMETRY_DISABLED 1 \
--set PRISMA_QUERY_ENGINE_LIBRARY "${prisma-engines'}/lib/libquery_engine.node" \
--set PRISMA_SCHEMA_ENGINE_BINARY "${prisma-engines'}/bin/schema-engine" \
--prefix PATH : ${
lib.makeBinPath [
openssl
nodejs
]
} \
--chdir $out \
--run "$out/node_modules/.bin/prisma migrate deploy" \
--add-flags "$out/server.js"
runHook postInstall
'';
passthru = {
tests = {
inherit (nixosTests) umami;
};
inherit
sources
geocities
;
prisma-engines = prisma-engines';
updateScript = ./update.sh;
};
meta = with lib; {
changelog = "https://github.com/umami-software/umami/releases/tag/v${finalAttrs.version}";
description = "Simple, easy to use, self-hosted web analytics solution";
homepage = "https://umami.is/";
license = with lib.licenses; [
mit
cc-by-40 # geocities
];
platforms = lib.platforms.linux;
mainProgram = "umami-server";
maintainers = with maintainers; [ diogotcorreia ];
};
})

View File

@ -0,0 +1,7 @@
{
"geocities": {
"rev": "0817bc800279e26e9ff045b7b129385e5b23012e",
"date": "2025-07-29",
"hash": "sha256-Rw9UEvUu7rtXFvHEqKza6kn9LwT6C17zJ/ljoN+t6Ek="
}
}

50
pkgs/by-name/um/umami/update.sh Executable file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p curl jq prefetch-yarn-deps nix-prefetch-github coreutils nix-update
# shellcheck shell=bash
# This script exists to update geocities version and pin prisma-engines version
set -euo pipefail
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
old_version=$(nix-instantiate --eval -A 'umami.version' default.nix | tr -d '"' || echo "0.0.1")
version=$(curl -s "https://api.github.com/repos/umami-software/umami/releases/latest" | jq -r ".tag_name")
version="${version#v}"
echo "Updating to $version"
if [[ "$old_version" == "$version" ]]; then
echo "Already up to date!"
exit 0
fi
nix-update --version "$version" umami
echo "Fetching geolite"
geocities_rev_date=$(curl https://api.github.com/repos/GitSquared/node-geolite2-redist/branches/master | jq -r ".commit.sha, .commit.commit.author.date")
geocities_rev=$(echo "$geocities_rev_date" | head -1)
geocities_date=$(echo "$geocities_rev_date" | tail -1 | sed 's/T.*//')
# upstream is kind enough to provide a file with the hash of the tar.gz archive
geocities_hash=$(curl -s "https://raw.githubusercontent.com/GitSquared/node-geolite2-redist/$geocities_rev/redist/GeoLite2-City.tar.gz.sha256")
geocities_hash_sri=$(nix-hash --to-sri --type sha256 "$geocities_hash")
cat <<EOF > "$SCRIPT_DIR/sources.json"
{
"geocities": {
"rev": "$geocities_rev",
"date": "$geocities_date",
"hash": "$geocities_hash_sri"
}
}
EOF
echo "Pinning Prisma version"
upstream_src="https://raw.githubusercontent.com/umami-software/umami/v$version"
lock=$(mktemp)
curl -s -o "$lock" "$upstream_src/pnpm-lock.yaml"
prisma_version=$(grep "@prisma/engines@" "$lock" | head -n1 | awk -F"[@']" '{print $4}')
rm "$lock"
nix-update --version "$prisma_version" umami.prisma-engines