Files
kurogeek 08b5496980 Package Penpot 2.18.3 for Nix with NixOS module; afk devShell
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
2026-10-20 17:45:02 +00:00

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
]);
}