Vendored from debling/nix-penpot (MIT). Fixes on top: - pnpm fetchers: drop --config.fetch-* flags (UND_ERR_INVALID_ARG on pnpm 11.27) and pin real store hashes (frontend x3, exporter, mcp) - deps-lock.json: add transitive funcool/beicon2 v2.0 so fake-git resolves potok2's pin during the shadow-cljs classpath build - expose frontend pnpm stores via passthru for rehashing - x86_64-linux only; devShell ships afk, jj, git
1211 lines
45 KiB
Nix
1211 lines
45 KiB
Nix
# NixOS module for self-hosting Penpot.
|
|
#
|
|
# Maps the upstream docker-compose
|
|
# (https://github.com/penpot/penpot/blob/develop/docker/images/docker-compose.yaml)
|
|
# deployment (backend + frontend + exporter + mcp + postgres + valkey
|
|
# + optional SMTP sink) onto native NixOS services. Infrastructure
|
|
# dependencies (PostgreSQL, Valkey) can be either provisioned by this
|
|
# module or reused from an existing setup; secrets are supplied
|
|
# through files and injected via systemd credentials.
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
inherit (lib)
|
|
concatStringsSep
|
|
mkAfter
|
|
mkBefore
|
|
mkDefault
|
|
mkEnableOption
|
|
mkIf
|
|
mkMerge
|
|
mkOption
|
|
optional
|
|
optionalAttrs
|
|
optionalString
|
|
types
|
|
;
|
|
|
|
cfg = config.services.penpot;
|
|
|
|
penpotUser = "penpot";
|
|
penpotGroup = "penpot";
|
|
|
|
# Assets directory used by the filesystem object-storage backend, shared
|
|
# between the backend (read/write) and the nginx vhost (read, via the
|
|
# /internal/assets alias).
|
|
assetsDir = cfg.storage.fs.directory;
|
|
|
|
# Which components to start.
|
|
exporterEnabled = cfg.exporter.enable;
|
|
mcpEnabled = cfg.mcp.enable;
|
|
|
|
# Parse "scheme://host[:port][/path][?query]" into components, or null.
|
|
parseUri =
|
|
uri:
|
|
let
|
|
m = builtins.match "[a-zA-Z][a-zA-Z0-9+.-]*://([^/:?#]+)(:([0-9]+))?(/[^?#]*)?(\\?.*)?" uri;
|
|
in
|
|
if m == null then
|
|
null
|
|
else
|
|
{
|
|
host = builtins.head m;
|
|
port = if builtins.elemAt m 2 == null then null else lib.toInt (builtins.elemAt m 2);
|
|
path = builtins.elemAt m 3;
|
|
};
|
|
|
|
isHttpUri = lib.hasPrefix "http://" (lib.toLower cfg.publicUri);
|
|
parsedPublicUri = parseUri cfg.publicUri;
|
|
vhostName = if parsedPublicUri != null then parsedPublicUri.host else null;
|
|
hasListenPort =
|
|
parsedPublicUri != null
|
|
&& parsedPublicUri.port != null
|
|
&& parsedPublicUri.port != 80
|
|
&& parsedPublicUri.port != 443;
|
|
|
|
# Rendered frontend configuration (the equivalent of the upstream
|
|
# `js/config.js` generated by the nginx entrypoint at container start).
|
|
# Flags and the public URI are not secret, so this lives in the store.
|
|
# Quotes and backslashes are escaped since both are interpolated into a
|
|
# JS string literal.
|
|
frontendConfigJs = pkgs.writeText "penpot-frontend-config.js" (
|
|
concatStringsSep "\n" (
|
|
[ "// Frontend configuration" ]
|
|
++ [
|
|
(optionalString (penpotFlags != "")
|
|
''var penpotFlags = "${lib.escape [ ''\"'' ''\\'' ] penpotFlags}";''
|
|
)
|
|
''var penpotPublicURI = "${lib.escape [ ''\"'' ''\\'' ] cfg.publicUri}";''
|
|
(optionalString (cfg.oidc.name != null)
|
|
''var penpotOIDCName = "${lib.escape [ ''\"'' ''\\'' ] cfg.oidc.name}";''
|
|
)
|
|
]
|
|
)
|
|
+ "\n"
|
|
);
|
|
|
|
# Extra flags derived from the module configuration, always appended after
|
|
# the user-provided ones. `lib.unique` guards against duplicates when the
|
|
# user already sets one of the derived flags.
|
|
derivedFlags = concatStringsSep " " (
|
|
lib.unique (
|
|
cfg.flags
|
|
++ optional (smtpConfigured) "enable-smtp"
|
|
++ optional mcpEnabled "enable-mcp"
|
|
++ optional isHttpUri "disable-secure-session-cookies"
|
|
++ optional (cfg.allowedOrigins != [ ]) "enable-cors"
|
|
)
|
|
);
|
|
penpotFlags = derivedFlags;
|
|
|
|
databaseCreateLocally = cfg.database.createLocally;
|
|
databaseUri = "postgresql://127.0.0.1/${cfg.database.name}";
|
|
databaseUriFinal = if databaseCreateLocally then databaseUri else cfg.database.uri;
|
|
|
|
redisCreateLocally = cfg.redis.createLocally;
|
|
redisUri = if redisCreateLocally then "redis://127.0.0.1:6379/0" else cfg.redis.uri;
|
|
|
|
smtpConfigured = cfg.smtp.host != null || cfg.mailpit.enable;
|
|
|
|
# IPv6 literal hosts are not recognized by parseUri (bracketed form),
|
|
# so loopback detection covers the name/IP forms only.
|
|
isLoopbackUri =
|
|
uri:
|
|
let
|
|
parsed = parseUri uri;
|
|
host = if parsed != null then parsed.host else null;
|
|
in
|
|
host != null
|
|
&& builtins.elem host [
|
|
"localhost"
|
|
"127.0.0.1"
|
|
];
|
|
|
|
# The exporter/mcp units only get the loopback network ACL when every
|
|
# peer they must reach is itself on loopback; an unparseable or external
|
|
# address (or a nonexistent redis) turns the ACL off.
|
|
exporterLoopbackOnly =
|
|
isLoopbackUri cfg.exporter.internalUri
|
|
&& (redisCreateLocally || (cfg.redis.uri != null && isLoopbackUri cfg.redis.uri));
|
|
|
|
mcpLoopbackOnly = cfg.mcp.redisUri == null || isLoopbackUri cfg.mcp.redisUri;
|
|
|
|
smtpPort = if cfg.mailpit.enable && cfg.smtp.host == null then 1025 else cfg.smtp.port;
|
|
smtpHost = if cfg.mailpit.enable && cfg.smtp.host == null then "127.0.0.1" else cfg.smtp.host;
|
|
smtpTls = if cfg.smtp.tls then "true" else "false";
|
|
smtpSsl = if cfg.smtp.ssl then "true" else "false";
|
|
|
|
backendEnv = {
|
|
PENPOT_FLAGS = penpotFlags;
|
|
PENPOT_PUBLIC_URI = cfg.publicUri;
|
|
PENPOT_HTTP_SERVER_MAX_BODY_SIZE = toString cfg.httpMaxBodySize;
|
|
# Loopback-only: the nginx vhost is the only intended client. Override
|
|
# via `services.penpot.environment` for split deployments.
|
|
PENPOT_HTTP_SERVER_HOST = "127.0.0.1";
|
|
PENPOT_DATABASE_URI = databaseUriFinal;
|
|
PENPOT_REDIS_URI = redisUri;
|
|
PENPOT_TELEMETRY_ENABLED = if cfg.telemetry.enable then "true" else "false";
|
|
}
|
|
// optionalAttrs (cfg.telemetry.enable) {
|
|
PENPOT_TELEMETRY_REFERER = "nixos";
|
|
}
|
|
// optionalAttrs (databaseCreateLocally || cfg.database.username != null) {
|
|
PENPOT_DATABASE_USERNAME =
|
|
if databaseCreateLocally then cfg.database.user else cfg.database.username;
|
|
}
|
|
// optionalAttrs (cfg.allowedOrigins != [ ]) {
|
|
PENPOT_ALLOWED_ORIGINS = concatStringsSep "," cfg.allowedOrigins;
|
|
}
|
|
// optionalAttrs (cfg.admins != [ ]) {
|
|
PENPOT_ADMINS = concatStringsSep "," cfg.admins;
|
|
}
|
|
// optionalAttrs (cfg.storage.backend == "fs") {
|
|
PENPOT_OBJECTS_STORAGE_BACKEND = "fs";
|
|
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY = assetsDir;
|
|
}
|
|
// optionalAttrs (cfg.storage.backend == "s3") {
|
|
PENPOT_OBJECTS_STORAGE_BACKEND = "s3";
|
|
PENPOT_OBJECTS_STORAGE_S3_ENDPOINT = cfg.storage.s3.endpoint;
|
|
PENPOT_OBJECTS_STORAGE_S3_BUCKET = cfg.storage.s3.bucket;
|
|
}
|
|
// optionalAttrs (cfg.storage.backend == "s3" && cfg.storage.s3.accessKeyId != null) {
|
|
AWS_ACCESS_KEY_ID = cfg.storage.s3.accessKeyId;
|
|
}
|
|
// optionalAttrs smtpConfigured {
|
|
PENPOT_SMTP_DEFAULT_FROM = cfg.smtp.from;
|
|
PENPOT_SMTP_DEFAULT_REPLY_TO = cfg.smtp.replyTo;
|
|
PENPOT_SMTP_HOST = smtpHost;
|
|
PENPOT_SMTP_PORT = toString smtpPort;
|
|
PENPOT_SMTP_TLS = smtpTls;
|
|
PENPOT_SMTP_SSL = smtpSsl;
|
|
}
|
|
// optionalAttrs (cfg.smtp.username != null) {
|
|
PENPOT_SMTP_USERNAME = cfg.smtp.username;
|
|
}
|
|
// optionalAttrs (cfg.jvmOpts != [ ]) {
|
|
JAVA_OPTS = concatStringsSep " " cfg.jvmOpts;
|
|
}
|
|
// cfg.environment;
|
|
|
|
exporterEnv = {
|
|
PENPOT_FLAGS = penpotFlags;
|
|
PENPOT_PUBLIC_URI = cfg.publicUri;
|
|
PENPOT_INTERNAL_URI = cfg.exporter.internalUri;
|
|
PENPOT_REDIS_URI = redisUri;
|
|
PENPOT_TEMPDIR = "/run/penpot-exporter";
|
|
# Chromium's crashpad needs a writable $HOME even with --disable-breakpad
|
|
# (it writes its crash database below it at startup). ProtectSystem=strict
|
|
# leaves the service user's passwd home (/var/lib/penpot) read-only, so
|
|
# every browser launch would die with SIGTRAP and exhaust the exporter's
|
|
# browser pool ("ResourceRequest timed out"). Point HOME at the unit's
|
|
# writable RuntimeDirectory instead.
|
|
HOME = "/run/penpot-exporter";
|
|
# NOTE: the exporter's http server ignores this variable (it always
|
|
# binds all interfaces); loopback isolation is enforced by the unit's
|
|
# IPAddressAllow/IPAddressDeny below.
|
|
PENPOT_HTTP_SERVER_HOST = "127.0.0.1";
|
|
}
|
|
// cfg.environment;
|
|
|
|
mcpEnv = {
|
|
# Loopback-only: nginx proxies /mcp/* to this server.
|
|
PENPOT_MCP_SERVER_HOST = "127.0.0.1";
|
|
}
|
|
// optionalAttrs (cfg.mcp.redisUri != null) {
|
|
PENPOT_MCP_REDIS_URI = cfg.mcp.redisUri;
|
|
};
|
|
|
|
# --- Secret handling ------------------------------------------------------
|
|
# All credentials are sourced from files through systemd's LoadCredential
|
|
# mechanism. Each unit loads only the credentials it needs and its
|
|
# `script` preamble exports them under the PENPOT_*/AWS_* names the
|
|
# services expect. Values are stripped of CR/LF so multi-line secret
|
|
# files cannot corrupt the environment.
|
|
|
|
backendCredentials = [
|
|
"secret-key:${cfg.secretKeyFile}"
|
|
]
|
|
++ optional (cfg.database.passwordFile != null) "database-password:${cfg.database.passwordFile}"
|
|
++ optional (cfg.smtp.passwordFile != null) "smtp-password:${cfg.smtp.passwordFile}"
|
|
++ optional (
|
|
cfg.storage.backend == "s3" && cfg.storage.s3.secretAccessKeyFile != null
|
|
) "s3-secret-key:${cfg.storage.s3.secretAccessKeyFile}";
|
|
|
|
exporterCredentials = [ "secret-key:${cfg.secretKeyFile}" ];
|
|
|
|
loadSecretKeySnippet = ''
|
|
export PENPOT_SECRET_KEY="$(tr -d '\r\n' < "$CREDENTIALS_DIRECTORY/secret-key")"
|
|
'';
|
|
|
|
backendLoadSecretsSnippet = ''
|
|
${loadSecretKeySnippet}
|
|
${optionalString (cfg.database.passwordFile != null) ''
|
|
export PENPOT_DATABASE_PASSWORD="$(tr -d '\r\n' < "$CREDENTIALS_DIRECTORY/database-password")"
|
|
''}
|
|
${optionalString (cfg.smtp.passwordFile != null) ''
|
|
export PENPOT_SMTP_PASSWORD="$(tr -d '\r\n' < "$CREDENTIALS_DIRECTORY/smtp-password")"
|
|
''}
|
|
${optionalString (cfg.storage.backend == "s3" && cfg.storage.s3.secretAccessKeyFile != null) ''
|
|
export AWS_SECRET_ACCESS_KEY="$(tr -d '\r\n' < "$CREDENTIALS_DIRECTORY/s3-secret-key")"
|
|
''}
|
|
'';
|
|
|
|
# Hardening shared by all Penpot services; each unit's serviceConfig
|
|
# merges this and adds its own settings.
|
|
commonServiceConfig = {
|
|
PrivateTmp = true;
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ProtectHome = true;
|
|
ProtectKernelTunables = true;
|
|
ProtectKernelModules = true;
|
|
ProtectControlGroups = true;
|
|
ProtectKernelLogs = true;
|
|
RestrictAddressFamilies = [
|
|
"AF_UNIX"
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
];
|
|
LockPersonality = true;
|
|
RestrictRealtime = true;
|
|
SystemCallArchitectures = "native";
|
|
Restart = "on-failure";
|
|
RestartSec = 5;
|
|
};
|
|
|
|
backendUri = "http://127.0.0.1:6060";
|
|
exporterUri = "http://127.0.0.1:6061";
|
|
mcpUri = "http://127.0.0.1:4401";
|
|
mcpWsUri = "http://127.0.0.1:4402";
|
|
|
|
# The security headers are re-included inside locations that set their own
|
|
# add_header directives, mirroring the upstream docker image (nginx drops
|
|
# inherited add_header lines when a location defines its own).
|
|
securityHeaders = pkgs.writeText "penpot-nginx-security-headers.conf" ''
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
|
add_header X-Frame-Options SAMEORIGIN always;
|
|
'';
|
|
|
|
nginxVhost = mkIf cfg.nginx.enable {
|
|
services.nginx.enable = mkDefault true;
|
|
# Host-wide (the option only exists at this level); mkDefault lets the
|
|
# user override it.
|
|
services.nginx.recommendedGzipSettings = mkDefault true;
|
|
services.nginx.virtualHosts.${vhostName} = {
|
|
serverName = vhostName;
|
|
listen = mkIf hasListenPort [
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = parsedPublicUri.port;
|
|
}
|
|
{
|
|
addr = "[::]";
|
|
port = parsedPublicUri.port;
|
|
}
|
|
];
|
|
forceSSL = cfg.nginx.forceHttps;
|
|
root = "${cfg.package.frontend}/dist";
|
|
extraConfig = ''
|
|
client_max_body_size ${toString cfg.httpMaxBodySize};
|
|
server_tokens off;
|
|
proxy_hide_header X-Powered-By;
|
|
# Upstream nginx.conf.template http-level settings.
|
|
proxy_connect_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
proxy_read_timeout 300s;
|
|
send_timeout 300s;
|
|
# The recommended gzip settings are enabled above at http level;
|
|
# this vhost needs a wider type list than that (nginx replaces,
|
|
# not extends, an inherited gzip_types), repeating it plus the
|
|
# penpot API's application/transit+json.
|
|
gzip_types application/atom+xml application/geo+json application/javascript application/json application/ld+json application/manifest+json application/rdf+xml application/transit+json application/vnd.ms-fontobject application/wasm application/x-rss+xml application/x-web-app-manifest+json application/xhtml+xml application/xliff+xml application/xml font/collection font/otf font/ttf image/bmp image/svg+xml image/vnd.microsoft.icon text/cache-manifest text/calendar text/css text/csv text/javascript text/markdown text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/xml;
|
|
charset utf-8;
|
|
etag off;
|
|
include ${securityHeaders};
|
|
'';
|
|
locations = mkMerge [
|
|
{
|
|
"= /js/config.js" = {
|
|
alias = frontendConfigJs;
|
|
extraConfig = ''
|
|
include ${securityHeaders};
|
|
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
|
'';
|
|
};
|
|
|
|
"~* \\.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$" = {
|
|
extraConfig = ''
|
|
include ${securityHeaders};
|
|
add_header Cache-Control "public, max-age=604800" always;
|
|
'';
|
|
};
|
|
|
|
"^~ /assets" = {
|
|
proxyPass = "${backendUri}/assets";
|
|
recommendedProxySettings = true;
|
|
extraConfig = ''
|
|
recursive_error_pages on;
|
|
proxy_intercept_errors on;
|
|
error_page 301 302 307 = @handle_redirect;
|
|
'';
|
|
};
|
|
|
|
"@handle_redirect" = {
|
|
extraConfig = ''
|
|
set $redirect_uri "$upstream_http_location";
|
|
set $redirect_host "$upstream_http_x_host";
|
|
set $redirect_cache_control "$upstream_http_cache_control";
|
|
set $real_mtype "$upstream_http_x_mtype";
|
|
|
|
proxy_buffering off;
|
|
|
|
proxy_set_header Host "$redirect_host";
|
|
proxy_set_header Authorization "";
|
|
proxy_hide_header etag;
|
|
proxy_hide_header x-amz-id-2;
|
|
proxy_hide_header x-amz-request-id;
|
|
proxy_hide_header x-amz-meta-server-side-encryption;
|
|
proxy_hide_header x-amz-server-side-encryption;
|
|
proxy_ssl_server_name on;
|
|
proxy_pass $redirect_uri;
|
|
|
|
include ${securityHeaders};
|
|
add_header x-internal-redirect "$redirect_uri";
|
|
add_header x-cache-control "$redirect_cache_control";
|
|
add_header cache-control "$redirect_cache_control";
|
|
add_header content-type "$real_mtype";
|
|
'';
|
|
};
|
|
|
|
"^~ /api" = {
|
|
proxyPass = "${backendUri}/api";
|
|
recommendedProxySettings = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
'';
|
|
};
|
|
|
|
# Static plugin files (trailing slash keeps gixy's alias-traversal
|
|
# check happy; bare /plugins requests get a nginx directory 301).
|
|
"^~ /plugins/" = {
|
|
alias = "${cfg.package.frontend}/dist/plugins/";
|
|
};
|
|
|
|
"= /readyz" = {
|
|
proxyPass = backendUri;
|
|
recommendedProxySettings = true;
|
|
extraConfig = ''
|
|
access_log off;
|
|
'';
|
|
};
|
|
|
|
"^~ /ws/notifications" = {
|
|
proxyPass = "${backendUri}/ws/notifications";
|
|
proxyWebsockets = true;
|
|
recommendedProxySettings = true;
|
|
};
|
|
|
|
"/" = {
|
|
tryFiles = "$uri /index.html$is_args$args /index.html =404";
|
|
extraConfig = ''
|
|
include ${securityHeaders};
|
|
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
|
'';
|
|
};
|
|
}
|
|
# Filesystem storage: internal alias serving assets directly. Both
|
|
# the location and the alias end in "/" (the backend x-accel-redirect
|
|
# paths use the "/internal/assets/" base) to avoid alias traversal.
|
|
(mkIf (cfg.nginx.enable && cfg.storage.backend == "fs") {
|
|
"^~ /internal/assets/" = {
|
|
extraConfig = ''
|
|
internal;
|
|
alias ${assetsDir}/;
|
|
include ${securityHeaders};
|
|
add_header x-internal-redirect "$upstream_http_x_accel_redirect";
|
|
'';
|
|
};
|
|
})
|
|
(mkIf exporterEnabled {
|
|
"^~ /api/export" = {
|
|
proxyPass = exporterUri;
|
|
recommendedProxySettings = true;
|
|
};
|
|
})
|
|
# Upstream nginx-mcp-locations.conf.template. Unlike upstream, the
|
|
# stream/sse proxy targets keep the original query string (SSE
|
|
# session ids travel in it); the variable proxy_pass passes the URI
|
|
# through verbatim, like the upstream `set`+`proxy_pass` pairing.
|
|
(mkIf mcpEnabled {
|
|
"^~ /mcp/ws" = {
|
|
proxyPass = mcpWsUri;
|
|
proxyWebsockets = true;
|
|
recommendedProxySettings = true;
|
|
};
|
|
"^~ /mcp/stream" = {
|
|
proxyPass = "${mcpUri}/mcp$is_args$args";
|
|
recommendedProxySettings = true;
|
|
};
|
|
"^~ /mcp/sse" = {
|
|
proxyPass = "${mcpUri}/sse$is_args$args";
|
|
recommendedProxySettings = true;
|
|
};
|
|
})
|
|
];
|
|
};
|
|
};
|
|
in
|
|
{
|
|
options.services.penpot = {
|
|
enable = mkEnableOption "Penpot, the open-source design and prototyping platform";
|
|
|
|
package = mkOption {
|
|
description = "Penpot component packages to use.";
|
|
type = types.submodule {
|
|
options = {
|
|
backend = mkOption {
|
|
description = "The Penpot backend (Clojure API server) package.";
|
|
type = types.package;
|
|
default = pkgs.penpot-backend;
|
|
defaultText = lib.literalExpression "pkgs.penpot-backend";
|
|
};
|
|
frontend = mkOption {
|
|
description = "The Penpot frontend (static site) package.";
|
|
type = types.package;
|
|
default = pkgs.penpot-frontend;
|
|
defaultText = lib.literalExpression "pkgs.penpot-frontend";
|
|
};
|
|
exporter = mkOption {
|
|
description = "The Penpot exporter (headless Chromium rendering) package.";
|
|
type = types.package;
|
|
default = pkgs.penpot-exporter;
|
|
defaultText = lib.literalExpression "pkgs.penpot-exporter";
|
|
};
|
|
mcp = mkOption {
|
|
description = "The Penpot MCP (Model Context Protocol) server package.";
|
|
type = types.package;
|
|
default = pkgs.penpot-mcp;
|
|
defaultText = lib.literalExpression "pkgs.penpot-mcp";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
publicUri = mkOption {
|
|
description = ''
|
|
Public URI of this Penpot instance ({env}`PENPOT_PUBLIC_URI`),
|
|
e.g. `https://penpot.example.com`. Must be set when
|
|
{option}`services.penpot.enable` is true.
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "https://penpot.example.com";
|
|
};
|
|
|
|
flags = mkOption {
|
|
description = ''
|
|
Penpot feature flags ({env}`PENPOT_FLAGS`). See the
|
|
[upstream configuration docs](https://help.penpot.app/technical-guide/configuration/)
|
|
for available flags. `enable-smtp`, `enable-mcp`,
|
|
`disable-secure-session-cookies` (for plain-HTTP deployments) and
|
|
`enable-cors` (when {option}`services.penpot.allowedOrigins` is
|
|
set) are appended automatically based on the module configuration,
|
|
and the resulting list is deduplicated.
|
|
'';
|
|
type = types.listOf types.str;
|
|
default = [ "enable-prepl-server" ];
|
|
example = [
|
|
"enable-prepl-server"
|
|
"enable-login-with-oidc"
|
|
"enable-registration"
|
|
];
|
|
};
|
|
|
|
secretKeyFile = mkOption {
|
|
description = ''
|
|
File containing the master secret key ({env}`PENPOT_SECRET_KEY`).
|
|
Used to derive HTTP session keys and invitation tokens. Generate one
|
|
with e.g. `openssl rand -base64 64`. Required.
|
|
'';
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
example = "/run/secrets/penpot-secret-key";
|
|
};
|
|
|
|
httpMaxBodySize = mkOption {
|
|
description = ''
|
|
Maximum HTTP body size accepted by the backend and the nginx vhost
|
|
({env}`PENPOT_HTTP_SERVER_MAX_BODY_SIZE`), in bytes. Defaults to
|
|
350 MiB like upstream.
|
|
'';
|
|
type = types.int;
|
|
default = 367001600;
|
|
};
|
|
|
|
jvmOpts = mkOption {
|
|
description = "Extra JVM options for the backend ({env}`JAVA_OPTS`).";
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [
|
|
"-Xms512m"
|
|
"-Xmx4g"
|
|
];
|
|
};
|
|
|
|
environment = mkOption {
|
|
description = ''
|
|
Extra environment variables for the backend and the exporter,
|
|
intended for upstream {env}`PENPOT_*` settings not covered by
|
|
module options (e.g. OIDC, LDAP or mail settings). Values are
|
|
written into the systemd unit, so do not put secrets here; use the
|
|
corresponding `*File` options instead.
|
|
'';
|
|
type = types.attrsOf types.str;
|
|
default = { };
|
|
example = {
|
|
PENPOT_OIDC_CLIENT_ID = "penpot";
|
|
PENPOT_OIDC_BASE_URI = "https://idp.example.com";
|
|
};
|
|
};
|
|
|
|
telemetry.enable = mkOption {
|
|
description = ''
|
|
Whether to enable the anonymized usage telemetry reports
|
|
({env}`PENPOT_TELEMETRY_ENABLED`).
|
|
'';
|
|
type = types.bool;
|
|
default = false;
|
|
};
|
|
|
|
oidc.name = mkOption {
|
|
description = ''
|
|
Display name for the OIDC login button on the login page
|
|
({env}`PENPOT_OIDC_NAME`). The remaining OIDC settings are passed
|
|
via {option}`services.penpot.environment`.
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
|
|
admins = mkOption {
|
|
description = ''
|
|
Email addresses of instance administrators
|
|
({env}`PENPOT_ADMINS`). The flag returned to the UI is computed
|
|
at login time, and authorization for administrative operations is
|
|
checked against this allowlist on every request, so changes apply
|
|
to already-logged-in sessions for backend authorizations, but the
|
|
UI shows the updated role after the next login. Registration always
|
|
creates non-admin users; there are no default credentials.
|
|
'';
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "admin@example.org" ];
|
|
};
|
|
|
|
allowedOrigins = mkOption {
|
|
description = ''
|
|
Explicit CORS allowlist ({env}`PENPOT_ALLOWED_ORIGINS`) for browsers
|
|
reaching the API from a different origin than
|
|
{option}`services.penpot.publicUri`. When non-empty, the
|
|
`enable-cors` flag is added and the backend answers preflights for
|
|
exactly these origins (fail-closed). Note that origins are matched
|
|
verbatim, scheme and port included, the browser always sends the
|
|
port for non-default ports. A standard single-origin deployment
|
|
needs nothing here: the UI and the API are both served from
|
|
{option}`services.penpot.publicUri`, so the browser never sends
|
|
cross-origin preflights.
|
|
'';
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
example = [ "https://plugins.example.com" ];
|
|
};
|
|
|
|
database = mkOption {
|
|
description = "PostgreSQL configuration for Penpot.";
|
|
type = types.submodule {
|
|
options = {
|
|
createLocally =
|
|
mkEnableOption ''
|
|
a local PostgreSQL instance ({option}`services.postgresql`) with
|
|
a dedicated database and role. Set to false to reuse an external
|
|
server, in which case {option}`services.penpot.database.uri`
|
|
must be provided.
|
|
''
|
|
// {
|
|
default = true;
|
|
};
|
|
name = mkOption {
|
|
description = "Database name used by Penpot.";
|
|
type = types.strMatching "[a-zA-Z_][a-zA-Z0-9_$]*";
|
|
default = "penpot";
|
|
};
|
|
user = mkOption {
|
|
description = ''
|
|
Database role used by Penpot. With `createLocally` the backend
|
|
connects over loopback TCP: without a password file a scoped
|
|
`trust` pg_hba rule authenticates this role, with one the role
|
|
password is provisioned from
|
|
{option}`services.penpot.database.passwordFile`.
|
|
'';
|
|
type = types.strMatching "[a-zA-Z_][a-zA-Z0-9_$]*";
|
|
default = "penpot";
|
|
};
|
|
uri = mkOption {
|
|
description = ''
|
|
Database connection URI ({env}`PENPOT_DATABASE_URI`) for
|
|
external servers, e.g.
|
|
`postgresql://db.example.com:5432/penpot`.
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
username = mkOption {
|
|
description = ''
|
|
Database user for external servers
|
|
({env}`PENPOT_DATABASE_USERNAME`).
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
passwordFile = mkOption {
|
|
description = ''
|
|
File containing the database password
|
|
({env}`PENPOT_DATABASE_PASSWORD`). With `createLocally` this
|
|
switches the local connection from the scoped `trust` rule to
|
|
the default md5 auth (the role password is provisioned from
|
|
this file on boot). Required for external servers that
|
|
authenticate by password.
|
|
'';
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
redis = mkOption {
|
|
description = "Valkey/Redis configuration for Penpot.";
|
|
type = types.submodule {
|
|
options = {
|
|
createLocally =
|
|
mkEnableOption ''
|
|
a local Valkey instance ({option}`services.redis`) used for
|
|
websocket notifications and export job queues. Set to false to
|
|
reuse an external server, in which case
|
|
{option}`services.penpot.redis.uri` must be provided.
|
|
''
|
|
// {
|
|
default = true;
|
|
};
|
|
uri = mkOption {
|
|
description = ''
|
|
Redis connection URI ({env}`PENPOT_REDIS_URI`) for external
|
|
servers, e.g. `redis://cache.example.com:6379/0`.
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
storage = mkOption {
|
|
description = "Storage backend for design assets and files.";
|
|
type = types.submodule {
|
|
options = {
|
|
backend = mkOption {
|
|
description = ''
|
|
Object-storage backend ({env}`PENPOT_OBJECTS_STORAGE_BACKEND`).
|
|
'';
|
|
type = types.enum [
|
|
"fs"
|
|
"s3"
|
|
];
|
|
default = "fs";
|
|
};
|
|
fs = mkOption {
|
|
description = "Filesystem backend settings.";
|
|
type = types.submodule {
|
|
options = {
|
|
directory = mkOption {
|
|
description = ''
|
|
Directory storing assets. Backed by the
|
|
`penpot` systemd state directory by default; the nginx
|
|
vhost serves it through the `/internal/assets` alias.
|
|
'';
|
|
type = types.str;
|
|
default = "/var/lib/penpot/assets";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
s3 = mkOption {
|
|
description = "S3-compatible backend settings.";
|
|
type = types.submodule {
|
|
options = {
|
|
endpoint = mkOption {
|
|
description = "S3 endpoint ({env}`PENPOT_OBJECTS_STORAGE_S3_ENDPOINT`).";
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "https://s3.amazonaws.com";
|
|
};
|
|
bucket = mkOption {
|
|
description = "S3 bucket name ({env}`PENPOT_OBJECTS_STORAGE_S3_BUCKET`).";
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
accessKeyId = mkOption {
|
|
description = "S3 access key ID ({env}`AWS_ACCESS_KEY_ID`).";
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
secretAccessKeyFile = mkOption {
|
|
description = ''
|
|
File containing the S3 secret access key
|
|
({env}`AWS_SECRET_ACCESS_KEY`).
|
|
'';
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
smtp = mkOption {
|
|
description = "Outgoing SMTP configuration for invitation and verification emails.";
|
|
type = types.submodule {
|
|
options = {
|
|
host = mkOption {
|
|
description = "SMTP server host ({env}`PENPOT_SMTP_HOST`).";
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
port = mkOption {
|
|
description = "SMTP server port ({env}`PENPOT_SMTP_PORT`).";
|
|
type = types.port;
|
|
default = 25;
|
|
};
|
|
username = mkOption {
|
|
description = "SMTP username ({env}`PENPOT_SMTP_USERNAME`), if required.";
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
passwordFile = mkOption {
|
|
description = ''
|
|
File containing the SMTP password
|
|
({env}`PENPOT_SMTP_PASSWORD`), if required.
|
|
'';
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
};
|
|
tls = mkOption {
|
|
description = "Whether to use STARTTLS ({env}`PENPOT_SMTP_TLS`).";
|
|
type = types.bool;
|
|
default = false;
|
|
};
|
|
ssl = mkOption {
|
|
description = "Whether to use implicit TLS ({env}`PENPOT_SMTP_SSL`).";
|
|
type = types.bool;
|
|
default = false;
|
|
};
|
|
from = mkOption {
|
|
description = "Sender address ({env}`PENPOT_SMTP_DEFAULT_FROM`).";
|
|
type = types.str;
|
|
default = "no-reply@example.com";
|
|
};
|
|
replyTo = mkOption {
|
|
description = "Reply-to address ({env}`PENPOT_SMTP_DEFAULT_REPLY_TO`).";
|
|
type = types.str;
|
|
default = "no-reply@example.com";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
mailpit.enable =
|
|
mkEnableOption ''
|
|
a local [mailpit](https://mailpit.axllent.org/) instance as a
|
|
throwaway SMTP sink (the equivalent of the upstream `mailcatch`
|
|
service). Emails are readable at `http://127.0.0.1:8025`. Only for
|
|
evaluation setups; use {option}`services.penpot.smtp` in production.
|
|
If {option}`services.penpot.smtp.host` is also set, the explicit SMTP
|
|
configuration wins and mailpit is only provisioned (not used for
|
|
sending).
|
|
''
|
|
// {
|
|
default = false;
|
|
};
|
|
|
|
exporter = mkOption {
|
|
description = "Penpot exporter (headless design export service).";
|
|
type = types.submodule {
|
|
options = {
|
|
enable = mkEnableOption "the Penpot exporter service" // {
|
|
default = true;
|
|
};
|
|
internalUri = mkOption {
|
|
description = ''
|
|
URI the exporter uses to fetch pages from the frontend vhost
|
|
({env}`PENPOT_INTERNAL_URI`). Defaults to the public URI's
|
|
host over plain HTTP, so the request's Host header routes it
|
|
to this module's vhost. With
|
|
{option}`services.penpot.nginx.forceHttps` enabled the plain
|
|
HTTP fetch is redirected to TLS, override this (or serve
|
|
plain HTTP on a vhost alias) so the exporter can follow it.
|
|
Also override it if that host is not reachable from the
|
|
machine itself; the value must then be a name the vhost
|
|
answers to (add one via
|
|
{option}`services.nginx.virtualHosts.<name>.serverAliases`).
|
|
'';
|
|
type = types.str;
|
|
default = "http://${vhostName}${optionalString hasListenPort ":${toString parsedPublicUri.port}"}";
|
|
defaultText = lib.literalExpression ''
|
|
"http://" + host of publicUri + (if it has an explicit port then ":<port>" else "")
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
mcp = mkOption {
|
|
description = "Penpot MCP (Model Context Protocol) server.";
|
|
type = types.submodule {
|
|
options = {
|
|
enable = mkEnableOption "the Penpot MCP server" // {
|
|
default = true;
|
|
};
|
|
redisUri = mkOption {
|
|
description = ''
|
|
Optional Redis URI enabling the multi-instance task routing
|
|
bridge ({env}`PENPOT_MCP_REDIS_URI`). Unset by default, which
|
|
runs the server in single-instance mode like upstream.
|
|
'';
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
nginx = mkOption {
|
|
description = "Reverse-proxy (nginx) configuration for the frontend.";
|
|
type = types.submodule {
|
|
options = {
|
|
enable =
|
|
mkEnableOption ''
|
|
the nginx vhost serving the frontend and proxying the backend,
|
|
exporter and MCP services. Required by both of them, since they
|
|
fetch rendered pages from the vhost.
|
|
''
|
|
// {
|
|
default = true;
|
|
};
|
|
forceHttps = mkOption {
|
|
description = ''
|
|
Whether to force HTTPS on the vhost
|
|
({option}`services.nginx.virtualHosts.<name>.forceSSL`).
|
|
Only meaningful once TLS termination is configured for the
|
|
vhost (e.g. via Let's Encrypt); see the nginx NixOS module.
|
|
'';
|
|
type = types.bool;
|
|
default = false;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
config = mkIf cfg.enable (mkMerge [
|
|
{
|
|
# The administrative CLI (create/update/delete-profile, change
|
|
# passwords, ...) talks to the backend prepl server on
|
|
# 127.0.0.1:6063, so it works out of the box on the machine running
|
|
# the backend. Requires the enable-prepl-server flag (default).
|
|
environment.systemPackages = [ cfg.package.backend ];
|
|
}
|
|
|
|
{
|
|
users.users.${penpotUser} = {
|
|
isSystemUser = true;
|
|
group = penpotGroup;
|
|
home = "/var/lib/penpot";
|
|
description = "Penpot service user";
|
|
};
|
|
users.groups.${penpotGroup} = { };
|
|
}
|
|
|
|
(mkIf (cfg.storage.backend == "fs") {
|
|
# Ensure the assets directory exists before any unit runs (required by
|
|
# the backend unit's ReadWritePaths) with correct ownership.
|
|
systemd.tmpfiles.rules = [
|
|
"d '${assetsDir}' 0755 ${penpotUser} ${penpotGroup} - -"
|
|
];
|
|
})
|
|
|
|
{
|
|
systemd.services.penpot-backend = {
|
|
description = "Penpot backend API server";
|
|
wantedBy = [ "multi-user.target" ];
|
|
requires = optional databaseCreateLocally "penpot-db-setup.service";
|
|
wants = [
|
|
"network-online.target"
|
|
]
|
|
++ optional databaseCreateLocally "postgresql-setup.service"
|
|
++ optional redisCreateLocally "redis-penpot.service";
|
|
after = [
|
|
"network-online.target"
|
|
]
|
|
++ optional databaseCreateLocally "postgresql-setup.service"
|
|
++ optional databaseCreateLocally "penpot-db-setup.service"
|
|
++ optional redisCreateLocally "redis-penpot.service";
|
|
environment = backendEnv;
|
|
serviceConfig = commonServiceConfig // {
|
|
User = penpotUser;
|
|
Group = penpotGroup;
|
|
StateDirectory = "penpot";
|
|
# A custom storage.fs.directory may live outside the state
|
|
# directory; grant write access explicitly. Note that the nginx
|
|
# vhost must be able to READ this path as well.
|
|
ReadWritePaths = mkIf (cfg.storage.backend == "fs") [ assetsDir ];
|
|
RestrictSUIDSGID = true;
|
|
TimeoutStopSec = 30;
|
|
LoadCredential = backendCredentials;
|
|
};
|
|
script = ''
|
|
${backendLoadSecretsSnippet}
|
|
exec ${lib.getExe cfg.package.backend}
|
|
'';
|
|
};
|
|
}
|
|
|
|
{
|
|
systemd.services.penpot-exporter = mkIf exporterEnabled {
|
|
description = "Penpot exporter (headless design export)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
environment = exporterEnv;
|
|
serviceConfig = commonServiceConfig // {
|
|
User = penpotUser;
|
|
Group = penpotGroup;
|
|
RuntimeDirectory = "penpot-exporter";
|
|
RuntimeDirectoryMode = "0750";
|
|
# The exporter's http server binds all interfaces; when every
|
|
# peer (vhost, redis) is on loopback, restrict the service to
|
|
# loopback traffic at the network level. Otherwise restrict
|
|
# access through the system firewall (on by default).
|
|
IPAddressDeny = mkIf exporterLoopbackOnly [ "any" ];
|
|
IPAddressAllow = mkIf exporterLoopbackOnly [ "localhost" ];
|
|
# Playwright launches Chromium; allow its process model.
|
|
KillMode = "mixed";
|
|
TimeoutStopSec = 15;
|
|
LoadCredential = exporterCredentials;
|
|
};
|
|
script = ''
|
|
${loadSecretKeySnippet}
|
|
exec ${lib.getExe cfg.package.exporter}
|
|
'';
|
|
};
|
|
|
|
systemd.services.penpot-mcp = mkIf mcpEnabled {
|
|
description = "Penpot MCP (Model Context Protocol) server";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
environment = mcpEnv;
|
|
serviceConfig = commonServiceConfig // {
|
|
User = penpotUser;
|
|
Group = penpotGroup;
|
|
ExecStart = "${cfg.package.mcp}/bin/penpot-mcp";
|
|
RuntimeDirectory = "penpot-mcp";
|
|
RuntimeDirectoryMode = "0700";
|
|
# The websocket listener binds all interfaces (only the HTTP
|
|
# server honors PENPOT_MCP_SERVER_HOST); when the configured redis
|
|
# (if any) is on loopback, restrict the service to loopback
|
|
# traffic. Otherwise restrict access through the system firewall.
|
|
IPAddressDeny = mkIf mcpLoopbackOnly [ "any" ];
|
|
IPAddressAllow = mkIf mcpLoopbackOnly [ "localhost" ];
|
|
# The server does not close its websocket listener on SIGTERM;
|
|
# let systemd escalate to SIGKILL after a short grace period.
|
|
TimeoutStopSec = 5;
|
|
};
|
|
};
|
|
}
|
|
|
|
{
|
|
# Dependency services when provisioned locally.
|
|
services.postgresql = mkIf databaseCreateLocally {
|
|
enable = mkDefault true;
|
|
ensureDatabases = [ cfg.database.name ];
|
|
ensureUsers = [
|
|
{
|
|
name = cfg.database.user;
|
|
ensureClauses.login = true;
|
|
}
|
|
];
|
|
};
|
|
}
|
|
|
|
# pgjdbc has no built-in unix-socket support, so the backend connects
|
|
# over loopback TCP. Without an explicit password file the penpot
|
|
# database is reachable from loopback without a password (a scoped
|
|
# `trust` rule, inserted before the module defaults); with one, the
|
|
# role password is provisioned below and the default md5 rule applies.
|
|
{
|
|
services.postgresql.authentication = mkBefore (
|
|
optionalString (databaseCreateLocally && cfg.database.passwordFile == null) ''
|
|
host ${cfg.database.name} ${cfg.database.user} 127.0.0.1/32 trust
|
|
''
|
|
);
|
|
}
|
|
|
|
# Provisioning that must run after the generic postgresql setup (which
|
|
# creates the database and role): the penpot role owns its database and
|
|
# schema (PostgreSQL 15+ no longer grants CREATE on `public` to other
|
|
# roles), and the optional role password comes from a credential.
|
|
{
|
|
systemd.services.penpot-db-setup = mkIf databaseCreateLocally {
|
|
description = "Provision the Penpot database ownership and credentials";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "postgresql-setup.service" ];
|
|
requires = [ "postgresql-setup.service" ];
|
|
path = [ config.services.postgresql.package ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
User = "postgres";
|
|
Group = "postgres";
|
|
LoadCredential = optional (
|
|
cfg.database.passwordFile != null
|
|
) "database-password:${cfg.database.passwordFile}";
|
|
};
|
|
# The database name and role are constrained to plain SQL
|
|
# identifiers by their option types, so interpolating them into the
|
|
# statements below is injection-safe. The password (free-form) is
|
|
# escaped by doubling single quotes.
|
|
script = ''
|
|
psql --dbname=postgres --command="ALTER DATABASE ${cfg.database.name} OWNER TO ${cfg.database.user}"
|
|
psql --dbname=${cfg.database.name} --command="GRANT ALL ON SCHEMA public TO ${cfg.database.user}"
|
|
${optionalString (cfg.database.passwordFile != null) ''
|
|
dbpass="$(tr -d '\r\n' < "$CREDENTIALS_DIRECTORY/database-password")"
|
|
# Piped via stdin so the password never appears in ps output.
|
|
printf 'ALTER ROLE ${cfg.database.user} PASSWORD %s\n' "'$(printf '%s' "$dbpass" | sed "s/'/'''/g")'" \
|
|
| psql --dbname=postgres
|
|
''}
|
|
'';
|
|
};
|
|
}
|
|
|
|
{
|
|
# The redis NixOS module only offers a top-level package option in
|
|
# this nixpkgs, so this is shared with any other configured redis
|
|
# servers. mkDefault lets the user override it.
|
|
services.redis.package = mkDefault pkgs.valkey;
|
|
|
|
services.redis.servers.penpot = mkIf redisCreateLocally {
|
|
enable = mkDefault true;
|
|
bind = "127.0.0.1";
|
|
port = 6379;
|
|
settings = {
|
|
maxmemory = "128mb";
|
|
maxmemory-policy = "volatile-lfu";
|
|
};
|
|
};
|
|
}
|
|
|
|
{
|
|
services.mailpit.instances.penpot = mkIf cfg.mailpit.enable {
|
|
listen = mkDefault "127.0.0.1:8025";
|
|
smtp = mkDefault "127.0.0.1:1025";
|
|
};
|
|
|
|
# Validation.
|
|
assertions = [
|
|
{
|
|
assertion = cfg.publicUri != null && cfg.publicUri != "";
|
|
message = "services.penpot.publicUri must be set (e.g. https://penpot.example.com).";
|
|
}
|
|
{
|
|
assertion = cfg.secretKeyFile != null;
|
|
message = "services.penpot.secretKeyFile must point to a file containing the Penpot master secret key.";
|
|
}
|
|
{
|
|
assertion =
|
|
parsedPublicUri == null
|
|
|| parsedPublicUri.path == null
|
|
|| parsedPublicUri.path == "/"
|
|
|| parsedPublicUri.path == "";
|
|
message = "services.penpot.publicUri must not contain a path component; Penpot does not support subpath deployments.";
|
|
}
|
|
{
|
|
assertion = !databaseCreateLocally || cfg.database.uri == null;
|
|
message = "services.penpot.database.uri must not be set when services.penpot.database.createLocally is enabled.";
|
|
}
|
|
{
|
|
assertion = databaseCreateLocally || cfg.database.uri != null;
|
|
message = "services.penpot.database.uri must be set when services.penpot.database.createLocally is disabled.";
|
|
}
|
|
{
|
|
assertion = !redisCreateLocally || cfg.redis.uri == null;
|
|
message = "services.penpot.redis.uri must not be set when services.penpot.redis.createLocally is enabled.";
|
|
}
|
|
{
|
|
assertion = redisCreateLocally || cfg.redis.uri != null;
|
|
message = "services.penpot.redis.uri must be set when services.penpot.redis.createLocally is disabled.";
|
|
}
|
|
{
|
|
assertion = !exporterEnabled || cfg.nginx.enable;
|
|
message = "services.penpot.exporter requires services.penpot.nginx.enable (it fetches pages from the vhost).";
|
|
}
|
|
{
|
|
assertion = !mcpEnabled || cfg.nginx.enable;
|
|
message = "services.penpot.mcp requires services.penpot.nginx.enable (the frontend proxies /mcp/* to it).";
|
|
}
|
|
{
|
|
assertion =
|
|
!(cfg.storage.backend == "s3")
|
|
|| (cfg.storage.s3.endpoint != null && cfg.storage.s3.bucket != null);
|
|
message = "services.penpot.storage.s3.endpoint and .bucket must be set when storage.backend is \"s3\".";
|
|
}
|
|
{
|
|
assertion = vhostName != null;
|
|
message = ''
|
|
services.penpot.publicUri is not a supported URI (cannot derive
|
|
the vhost host name). Hostname-only hosts are expected; IPv6
|
|
literal hosts are not supported.
|
|
'';
|
|
}
|
|
{
|
|
assertion = !databaseCreateLocally || config.services.postgresql.enable;
|
|
message = "services.penpot.database.createLocally requires services.postgresql.enable.";
|
|
}
|
|
{
|
|
assertion = !redisCreateLocally || config.services.redis.servers.penpot.enable or false;
|
|
message = "services.penpot.redis.createLocally requires services.redis.servers.penpot.enable.";
|
|
}
|
|
{
|
|
assertion = !cfg.nginx.enable || config.services.nginx.enable;
|
|
message = "services.penpot.nginx.enable requires services.nginx.enable.";
|
|
}
|
|
];
|
|
|
|
warnings =
|
|
optional isHttpUri "services.penpot.publicUri is plain HTTP; the disable-secure-session-cookies flag has been added automatically. Use HTTPS in production."
|
|
++
|
|
optional (databaseCreateLocally && cfg.database.passwordFile == null)
|
|
"services.penpot.database.createLocally has no password file; the penpot database accepts loopback connections without a password (scoped pg_hba trust rule). Set services.penpot.database.passwordFile for stricter auth.";
|
|
}
|
|
|
|
nginxVhost
|
|
]);
|
|
}
|