# 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..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 ":" 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..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 ]); }