Add flake

This commit is contained in:
Alois 2026-08-22 11:11:34 +02:00
commit 945846e0b5
No known key found for this signature in database
3 changed files with 829 additions and 1 deletions

18
LICENSE Normal file
View file

@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 Methanium
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,2 +1,56 @@
# nextcloud
# Nextcloud
Example:
```nix
{ config, ... }:
{
sops.secrets = {
nextcloud-jwt = { };
nextcloud-oidc = { };
nextcloud-admin-password = { };
};
custom.nextcloud = {
enable = true;
hostName = "cloud.example.com";
adminUser = "administrator";
useAuthentikSSO = true;
secrets = {
adminPasswordFile = config.sops.secrets.nextcloud-admin-password.path;
jwtFile = config.sops.secrets.nextcloud-jwt.path;
oidcClientSecretFile = config.sops.secrets.nextcloud-oidc.path;
};
nginx.useACMEHost = "example.com";
anubis.enable = true;
authentik = {
hostName = "auth.example.com";
providerName = "My authentik provider";
blueprint.enable = true;
};
addons = {
apps = [
"mail"
"deck"
];
whiteboard = {
enable = true;
hostName = "whiteboad.example.com";
disableRecording = true;
};
eurooffice = {
enable = true;
hostName = "eurooffice.example.com";
};
};
};
}
```

756
flake.nix Normal file
View file

@ -0,0 +1,756 @@
{
description = "Nextcloud";
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = inputs: {
nixosModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
inherit (lib)
concatStringsSep
hasAttrByPath
mkEnableOption
mkIf
mkMerge
mkOption
optional
optionalAttrs
setAttrByPath
types
unique
;
cfg = config.custom.nextcloud;
nextcloudUrl = "https://${cfg.hostName}";
whiteboardUrl =
if cfg.addons.whiteboard.externalUrl != null then
cfg.addons.whiteboard.externalUrl
else
"https://${cfg.addons.whiteboard.hostName}";
euroofficeUrl =
if cfg.addons.eurooffice.externalUrl != null then
cfg.addons.eurooffice.externalUrl
else
"https://${cfg.addons.eurooffice.hostName}";
appSet = cfg.package.packages.apps;
packagedAppNames = unique (
cfg.addons.apps
++ optional cfg.useAuthentikSSO "user_oidc"
++ optional cfg.addons.whiteboard.enable "whiteboard"
);
packagedApps = lib.genAttrs packagedAppNames (
name:
if builtins.hasAttr name appSet then
builtins.getAttr name appSet
else
throw "custom.nextcloud: Nextcloud app '${name}' is not packaged for ${
cfg.package.name or "the selected Nextcloud package"
}"
);
euroofficeApp = pkgs.fetchNextcloudApp {
url = cfg.addons.eurooffice.app.url;
hash = cfg.addons.eurooffice.app.hash;
license = cfg.addons.eurooffice.app.license;
};
allExtraApps =
packagedApps
// optionalAttrs cfg.addons.eurooffice.enable {
eurooffice = euroofficeApp;
}
// cfg.addons.extraApps;
nginxTls = {
forceSSL = cfg.nginx.forceSSL;
enableACME = cfg.nginx.enableACME;
useACMEHost = cfg.nginx.useACMEHost;
};
publicProxyTarget =
if cfg.anubis.enable then
"http://${cfg.bindAddress}:${toString cfg.anubis.port}"
else
"http://${cfg.bindAddress}:${toString cfg.rawPort}";
authentikDiscoveryUrl =
if cfg.authentik.discoveryUrl != null then
cfg.authentik.discoveryUrl
else
"https://${cfg.authentik.hostName}/application/o/${cfg.authentik.applicationSlug}/.well-known/openid-configuration";
authentikBlueprint = ''
version: 1
metadata:
name: Nextcloud
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_core.group
identifiers:
name: ${cfg.authentik.groupName}
attrs:
is_superuser: false
- model: authentik_providers_oauth2.oauth2provider
identifiers:
name: ${cfg.authentik.providerName}
attrs:
name: ${cfg.authentik.providerName}
client_type: confidential
client_id: ${cfg.authentik.clientId}
client_secret: !Env ${cfg.authentik.blueprint.secretEnvName}
grant_types:
- authorization_code
- refresh_token
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
redirect_uris:
- matching_mode: strict
url: ${nextcloudUrl}/apps/user_oidc/code
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
- model: authentik_core.application
identifiers:
slug: ${cfg.authentik.applicationSlug}
attrs:
name: Nextcloud
slug: ${cfg.authentik.applicationSlug}
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, ${cfg.authentik.providerName}]]
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, ${cfg.authentik.applicationSlug}]]
group: !Find [authentik_core.group, [name, ${cfg.authentik.groupName}]]
order: 0
attrs:
enabled: true
'';
whiteboardModuleAvailable = hasAttrByPath [ "services" "nextcloud-whiteboard-server" ] options;
anubisModuleAvailable = hasAttrByPath [ "services" "anubis" "instances" ] options;
authentikTargetAvailable = hasAttrByPath cfg.authentik.blueprint.targetOption options;
whiteboardStart = pkgs.writeShellScript "nextcloud-whiteboard-with-secret" ''
set -euo pipefail
export JWT_SECRET_KEY="$(<"$CREDENTIALS_DIRECTORY/jwt")"
exec ${lib.getExe pkgs.nextcloud-whiteboard-server}
'';
configScript = pkgs.writeShellScript "nextcloud-stack-config" ''
set -euo pipefail
OCC=${lib.escapeShellArg "${config.services.nextcloud.occ}/bin/nextcloud-occ"}
"$OCC" config:app:set files default_quota --value ${lib.escapeShellArg cfg.defaultQuota}
${lib.optionalString cfg.addons.whiteboard.enable ''
jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")"
"$OCC" config:app:set whiteboard collabBackendUrl --value=${lib.escapeShellArg "${whiteboardUrl}/"}
"$OCC" config:app:set whiteboard jwt_secret_key --value="$jwt_secret"
''}
${lib.optionalString cfg.addons.eurooffice.enable ''
jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")"
"$OCC" config:app:set eurooffice StorageUrl --value=${lib.escapeShellArg "${nextcloudUrl}/"}
"$OCC" config:app:set eurooffice DocumentServerUrl --value=${lib.escapeShellArg "${euroofficeUrl}/"}
"$OCC" config:app:set eurooffice jwt_secret --value="$jwt_secret"
"$OCC" config:app:set eurooffice jwt_header --value=${lib.escapeShellArg cfg.addons.eurooffice.jwtHeader}
"$OCC" config:app:set eurooffice editFormats --value=${lib.escapeShellArg cfg.addons.eurooffice.editFormats}
''}
${lib.optionalString cfg.useAuthentikSSO ''
oidc_secret="$(<"$CREDENTIALS_DIRECTORY/oidc")"
"$OCC" user_oidc:provider ${lib.escapeShellArg cfg.authentik.providerName} \
--clientid=${lib.escapeShellArg cfg.authentik.clientId} \
--clientsecret="$oidc_secret" \
--discoveryuri=${lib.escapeShellArg authentikDiscoveryUrl} \
--unique-uid=${if cfg.authentik.uniqueUid then "1" else "0"}
''}
${concatStringsSep "\n" cfg.addons.occCommands}
'';
in
{
options.custom.nextcloud = {
enable = mkEnableOption "Nextcloud";
hostName = mkOption {
type = types.str;
description = "Public Nextcloud hostname.";
example = "cloud.example.com";
};
bindAddress = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Loopback address used by internal services.";
};
rawPort = mkOption {
type = types.port;
default = 5003;
description = "Port for the raw Nextcloud nginx vhost.";
};
package = mkOption {
type = types.package;
default = pkgs.nextcloud34;
defaultText = lib.literalExpression "pkgs.nextcloud34";
description = "Nextcloud package.";
};
adminUser = mkOption {
type = types.nullOr types.str;
default = "admin";
description = "Initial admin username. Set to null to disable initial admin creation on Nextcloud 32+.";
};
maxUploadSize = mkOption {
type = types.str;
default = "1G";
};
defaultQuota = mkOption {
type = types.str;
default = "10 GB";
};
appstoreEnable = mkOption {
type = types.bool;
default = false;
};
settings = mkOption {
type = types.attrs;
default = { };
description = "Extra services.nextcloud.settings values.";
};
useAuthentikSSO = mkOption {
type = types.bool;
default = false;
description = "Configure the user_oidc app for Authentik.";
};
secrets = {
adminPasswordFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime file containing the initial Nextcloud admin password.";
};
jwtFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime file containing the shared JWT secret for addons.";
};
oidcClientSecretFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime file containing the Authentik OIDC client secret.";
};
nextcloud = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Extra secret config.php entries passed to services.nextcloud.secrets.";
};
};
database = {
type = mkOption {
type = types.enum [
"mysql"
"pgsql"
"sqlite"
];
default = "mysql";
};
createLocally = mkOption {
type = types.bool;
default = true;
};
name = mkOption {
type = types.str;
default = "nextcloud";
};
user = mkOption {
type = types.str;
default = "nextcloud";
};
host = mkOption {
type = types.nullOr types.str;
default = null;
description = "Database host. Leave null when createLocally is true.";
};
passwordFile = mkOption {
type = types.nullOr types.str;
default = null;
};
};
nginx = {
forceSSL = mkOption {
type = types.bool;
default = true;
};
enableACME = mkOption {
type = types.bool;
default = false;
};
useACMEHost = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
};
extraPublicConfig = mkOption {
type = types.lines;
default = "";
};
};
anubis = {
enable = mkOption {
type = types.bool;
default = false;
};
port = mkOption {
type = types.port;
default = 7003;
};
settings = mkOption {
type = types.attrs;
default = { };
};
};
authentik = {
hostName = mkOption {
type = types.str;
default = "auth.example.com";
};
discoveryUrl = mkOption {
type = types.nullOr types.str;
default = null;
};
clientId = mkOption {
type = types.str;
default = "nextcloud";
};
providerName = mkOption {
type = types.str;
default = "Authentik";
};
applicationSlug = mkOption {
type = types.str;
default = "nextcloud";
};
groupName = mkOption {
type = types.str;
default = "nextcloud-users";
};
uniqueUid = mkOption {
type = types.bool;
default = false;
};
hideLoginForm = mkOption {
type = types.bool;
default = true;
};
blueprint = {
enable = mkOption {
type = types.bool;
default = false;
description = "Generate an Authentik blueprint under /etc/nextcloud.";
};
autoAttach = mkOption {
type = types.bool;
default = true;
description = "Also attach the blueprint to targetOption when that option exists.";
};
targetOption = mkOption {
type = types.listOf types.str;
default = [
"methanium"
"authentik"
"blueprints"
"nextcloud"
];
};
secretEnvName = mkOption {
type = types.str;
default = "NEXTCLOUD_OIDC_SECRET";
};
};
};
addons = {
apps = mkOption {
type = types.listOf types.str;
default = [
"mail"
"deck"
];
description = "App names from the selected Nextcloud package app set.";
};
extraApps = mkOption {
type = types.attrsOf types.package;
default = { };
description = "Additional app packages merged into services.nextcloud.extraApps.";
};
occCommands = mkOption {
type = types.listOf types.lines;
default = [ ];
description = "Extra idempotent shell commands run after nextcloud-setup.";
};
whiteboard = {
enable = mkEnableOption "Nextcloud Whiteboard";
hostName = mkOption {
type = types.str;
default = "whiteboard.example.com";
};
externalUrl = mkOption {
type = types.nullOr types.str;
default = null;
description = "External Whiteboard URL. Defaults to https://hostName.";
};
port = mkOption {
type = types.port;
default = 5004;
};
nginx.enable = mkOption {
type = types.bool;
default = true;
};
disableRecording = mkOption {
type = types.bool;
default = false;
description = "Apply the custom DISABLE_RECORDING patch from the original configuration.";
};
settings = mkOption {
type = types.attrsOf types.str;
default = { };
};
};
eurooffice = {
enable = mkEnableOption "EuroOffice document server integration";
hostName = mkOption {
type = types.str;
default = "office.example.com";
};
externalUrl = mkOption {
type = types.nullOr types.str;
default = null;
description = "External EuroOffice URL. Defaults to https://hostName.";
};
port = mkOption {
type = types.port;
default = 5005;
};
nginx.enable = mkOption {
type = types.bool;
default = true;
};
image = mkOption {
type = types.str;
default = "ghcr.io/euro-office/documentserver:latest";
};
jwtHeader = mkOption {
type = types.str;
default = "Authorization";
};
editFormats = mkOption {
type = types.str;
default = ''{"docm":true,"docx":true,"dotm":true,"dotx":true,"xlsm":true,"xlsx":true,"pptm":true,"pptx":true,"pdf":true}'';
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
};
app = {
url = mkOption {
type = types.str;
default = "https://github.com/nextcloud-releases/eurooffice/releases/download/v11.0.1/eurooffice-v11.0.1.tar.gz";
};
hash = mkOption {
type = types.str;
default = "sha256-HXpvyCNhlxAvrxSEu6/5u0mpg7TrTsaS2gii9mf74ns=";
};
license = mkOption {
type = types.str;
default = "asl20";
};
};
};
};
};
config = mkIf cfg.enable (mkMerge [
{
assertions = [
{
assertion = cfg.adminUser == null || cfg.secrets.adminPasswordFile != null;
message = "custom.nextcloud.secrets.adminPasswordFile is required when adminUser is not null.";
}
{
assertion =
!(cfg.addons.whiteboard.enable || cfg.addons.eurooffice.enable) || cfg.secrets.jwtFile != null;
message = "custom.nextcloud.secrets.jwtFile is required by Whiteboard and EuroOffice.";
}
{
assertion = !cfg.useAuthentikSSO || cfg.secrets.oidcClientSecretFile != null;
message = "custom.nextcloud.secrets.oidcClientSecretFile is required when useAuthentikSSO is true.";
}
{
assertion = !cfg.addons.whiteboard.enable || whiteboardModuleAvailable;
message = "The selected nixpkgs does not provide services.nextcloud-whiteboard-server.";
}
{
assertion = !cfg.anubis.enable || anubisModuleAvailable;
message = "The selected nixpkgs does not provide services.anubis.instances.";
}
{
assertion = !cfg.authentik.blueprint.enable || cfg.useAuthentikSSO;
message = "authentik.blueprint.enable requires useAuthentikSSO = true.";
}
];
services.nextcloud = {
enable = true;
package = cfg.package;
hostName = "raw-" + cfg.hostName;
appstoreEnable = cfg.appstoreEnable;
maxUploadSize = cfg.maxUploadSize;
configureRedis = true;
database.createLocally = cfg.database.createLocally;
config = {
adminuser = cfg.adminUser;
adminpassFile = cfg.secrets.adminPasswordFile;
dbtype = cfg.database.type;
dbname = cfg.database.name;
dbuser = cfg.database.user;
}
// optionalAttrs (cfg.database.host != null) {
dbhost = cfg.database.host;
}
// optionalAttrs (cfg.database.passwordFile != null) {
dbpassFile = cfg.database.passwordFile;
};
https = true;
settings = mkMerge [
{
trusted_domains = [ cfg.hostName ];
trusted_proxies = unique [
cfg.bindAddress
"127.0.0.1"
"::1"
];
overwritehost = cfg.hostName;
overwriteprotocol = "https";
}
(mkIf cfg.useAuthentikSSO {
hide_login_form = cfg.authentik.hideLoginForm;
})
cfg.settings
];
secrets = cfg.secrets.nextcloud;
extraAppsEnable = true;
extraApps = allExtraApps;
};
services.nginx.virtualHosts.${"raw-" + cfg.hostName}.listen = lib.mkForce [
{
addr = cfg.bindAddress;
port = cfg.rawPort;
}
];
services.nginx.virtualHosts.${cfg.hostName} = nginxTls // {
extraConfig = cfg.nginx.extraPublicConfig;
locations."/" = {
proxyPass = publicProxyTarget;
proxyWebsockets = true;
};
};
systemd.services.nextcloud-stack-config = {
description = "Declarative Nextcloud stack configuration";
after = [ "nextcloud-setup.service" ];
requires = [ "nextcloud-setup.service" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ configScript ];
serviceConfig = {
Type = "oneshot";
User = "nextcloud";
LoadCredential =
optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}"
++ optional (cfg.secrets.oidcClientSecretFile != null) "oidc:${cfg.secrets.oidcClientSecretFile}"
++ optional (cfg.database.passwordFile != null) "dbpass:${cfg.database.passwordFile}"
++ lib.mapAttrsToList (name: path: "${name}:${path}") cfg.secrets.nextcloud;
ExecStart = configScript;
};
};
}
(mkIf (cfg.anubis.enable && anubisModuleAvailable) {
services.anubis.instances.nextcloud.settings = {
BIND = "${cfg.bindAddress}:${toString cfg.anubis.port}";
TARGET = "http://${cfg.bindAddress}:${toString cfg.rawPort}";
}
// cfg.anubis.settings;
})
(mkIf (cfg.addons.whiteboard.enable && whiteboardModuleAvailable) {
services.nextcloud-whiteboard-server = {
enable = true;
secrets = [ ];
settings = {
NEXTCLOUD_URL = nextcloudUrl;
HOST = cfg.bindAddress;
PORT = toString cfg.addons.whiteboard.port;
}
// cfg.addons.whiteboard.settings;
};
systemd.services.nextcloud-whiteboard-server.serviceConfig = {
LoadCredential = optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}";
ExecStart = lib.mkForce whiteboardStart;
};
})
(mkIf (cfg.addons.whiteboard.enable && cfg.addons.whiteboard.nginx.enable) {
services.nginx.virtualHosts.${cfg.addons.whiteboard.hostName} = nginxTls // {
locations."/" = {
proxyPass = "http://${cfg.bindAddress}:${toString cfg.addons.whiteboard.port}";
proxyWebsockets = true;
};
};
})
(mkIf (cfg.addons.whiteboard.enable && cfg.addons.whiteboard.disableRecording) {
nixpkgs.overlays = [
(final: prev: {
nextcloud-whiteboard-server = prev.nextcloud-whiteboard-server.overrideAttrs (old: {
postPatch = (old.postPatch or "") + ''
substituteInPlace websocket_server/SocketManager.js \
--replace-fail $'const support = await checkPuppeteerAvailability({ force: false })\n\t\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\tavailable: support.available,\n\t\t\t\treason: support.reason || null,\n\t\t\t})' $'if (process.env.DISABLE_RECORDING === "1") {\n\t\t\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\t\tavailable: false,\n\t\t\t\t\treason: null,\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst support = await checkPuppeteerAvailability({ force: false })\n\t\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\tavailable: support.available,\n\t\t\t\treason: support.reason || null,\n\t\t\t})'
'';
});
})
];
services.nextcloud-whiteboard-server.settings.DISABLE_RECORDING = "1";
})
(mkIf cfg.addons.eurooffice.enable {
virtualisation.oci-containers.containers.nextcloud-eurooffice = {
serviceName = "nextcloud-eurooffice";
image = cfg.addons.eurooffice.image;
ports = [ "${cfg.bindAddress}:${toString cfg.addons.eurooffice.port}:80" ];
environmentFiles = [ "/run/nextcloud-eurooffice/eurooffice.env" ];
environment = {
JWT_ENABLED = "true";
JWT_HEADER = cfg.addons.eurooffice.jwtHeader;
ALLOW_PRIVATE_IP_ADDRESS = "true";
}
// cfg.addons.eurooffice.environment;
};
systemd.services.nextcloud-eurooffice = {
serviceConfig = {
LoadCredential = optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}";
RuntimeDirectory = "nextcloud-eurooffice";
RuntimeDirectoryMode = "0700";
};
preStart = ''
set -euo pipefail
jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")"
umask 077
printf 'JWT_SECRET=%s\n' "$jwt_secret" > /run/nextcloud-eurooffice/eurooffice.env
'';
};
})
(mkIf (cfg.addons.eurooffice.enable && cfg.addons.eurooffice.nginx.enable) {
services.nginx.virtualHosts.${cfg.addons.eurooffice.hostName} = nginxTls // {
locations."/" = {
proxyPass = "http://${cfg.bindAddress}:${toString cfg.addons.eurooffice.port}";
proxyWebsockets = true;
};
};
})
(mkIf cfg.authentik.blueprint.enable {
environment.etc."nextcloud/authentik-blueprint.yaml".text = authentikBlueprint;
})
(mkIf (
cfg.authentik.blueprint.enable && cfg.authentik.blueprint.autoAttach && authentikTargetAvailable
) (setAttrByPath cfg.authentik.blueprint.targetOption authentikBlueprint))
]);
};
};
}