# Penpot Admin & Developer Guide

This guide is for the people who **deploy, operate, secure, and extend** Penpot:
administrators, DevOps/SRE, platform architects, and contributors. It documents
the system architecture, the complete configuration surface, deployment with
Docker, security model, day-2 operations, the internal code structure, and the
plugin/MCP extension points.

> [!NOTE]
> Penpot is a polyglot, multi-service application licensed under **MPL-2.0** and
> developed by Kaleidos. For using the product (designing, prototyping,
> collaborating), see the **Penpot User Guide**. This document focuses on
> running and building it.

---

## 1. Executive Summary

Penpot is a self-hostable web platform for design and prototyping. It is
composed of several cooperating services written in different languages, each
chosen for its job:

| Service | Language / runtime | Role |
|---------|--------------------|------|
| **frontend** | ClojureScript (compiled to JS via shadow-cljs), React 19; served by nginx | The single-page design application served to browsers |
| **backend** | Clojure on the JVM | HTTP API, RPC command layer, auth, persistence, background workers |
| **exporter** | ClojureScript on Node.js | Renders designs to images/exports by driving a headless browser (Playwright) |
| **mcp** | TypeScript on Node.js | Model Context Protocol server exposing the design API to AI clients |
| **render engine** | Rust → WebAssembly (Skia) | High-performance canvas rendering, shipped inside the frontend |
| **PostgreSQL** | — | System of record (files, teams, users, tasks) |
| **Valkey** (Redis-compatible) | — | WebSocket pub/sub and the background-task queue |

Key architectural properties:

- **Single secret root.** One `PENPOT_SECRET_KEY` derives keys for sessions,
  invitations, and other subsystems.
- **Config by environment.** All runtime configuration is read from
  `PENPOT_*` environment variables and validated at startup against a schema.
- **RPC command layer.** The API is organized as a large set of named *query*
  and *mutation* commands rather than ad-hoc REST endpoints.
- **Asynchronous work.** Email, exports, file maintenance, and scheduled jobs
  run through a PostgreSQL + Valkey-backed worker with cron support.
- **Open formats and extensibility.** Files export to an open `.penpot` format;
  a typed Plugin API and an MCP server provide programmatic access.

---

## 2. Architecture

### 2.1 Runtime topology

```text
                          Browser (designers, developers, viewers)
                                        │  HTTPS
                                        ▼
                         ┌───────────────────────────────┐
                         │  penpot-frontend (nginx)       │  :9001 → :8080
                         │  - serves the SPA (CLJS+React) │
                         │  - bundled WASM render engine  │
                         │  - reverse-proxies API/exporter│
                         └───────────────┬───────────────┘
                                         │
        ┌────────────────────────────────┼─────────────────────────────┐
        ▼                                 ▼                             ▼
┌──────────────────┐          ┌────────────────────┐        ┌───────────────────┐
│ penpot-backend   │          │ penpot-exporter    │        │ penpot-mcp        │
│ (Clojure / JVM)  │          │ (CLJS / Node +     │        │ (TS / Node;       │
│ HTTP + RPC + wrk │          │  Playwright)       │        │  MCP tools)       │
└───┬─────────┬────┘          └─────────┬──────────┘        └─────────┬─────────┘
    │         │                         │                             │
    ▼         ▼                         ▼                             │
┌────────┐ ┌─────────────┐       (renders via                        │
│Postgres│ │ Valkey      │◄───── frontend in a headless browser)     │
│  :15   │ │ (Redis API) │                                           │
└────────┘ └─────────────┘                                           │
   ▲  ▲          ▲                                                    │
   │  │          └──────────── pub/sub (live collab) + task queue ───┘
   │  └─ system of record (files, teams, users, tasks, fonts, media)
   └──── object storage for assets: filesystem volume or S3-compatible
```

### 2.2 Service responsibilities

- **frontend** — Builds and serves the SPA. The drawing canvas is powered by a
  Rust+Skia renderer compiled to WebAssembly and bundled into the frontend.
  nginx terminates HTTP and routes API/exporter traffic to the right backend
  service.
- **backend** — The core. Hosts the HTTP server, the RPC command layer, auth,
  database access, object storage, email, audit logging, and the background
  worker (cron + task runner). Built as an uberjar and run on the JVM.
- **exporter** — Produces image and archive exports. It drives a headless
  browser (Playwright) that loads the frontend to render designs faithfully,
  optimizes SVG (svgo), and packages multi-asset exports (archiver). It uses
  Valkey for coordination.
- **mcp** — A Node/TypeScript MCP server that registers Penpot's plugin
  capabilities as MCP tools and bridges them to a running Penpot instance for AI
  clients.
- **PostgreSQL** — Durable storage for all domain data and the task table.
- **Valkey** — WebSocket notifications for real-time collaboration and the
  transport for the worker's task queue.

### 2.3 Backend internals (high level)

The backend is a Clojure application wired together with **Integrant** (a
dependency-injection / lifecycle library). Notable building blocks:

| Concern | Implementation |
|---------|----------------|
| **Config** | `app.config` — reads `PENPOT_*` env vars, validates against a Malli schema, computes feature flags |
| **Database** | `app.db` — HikariCP connection pool over PostgreSQL via `next.jdbc`; helpers for queries, transactions, advisory locks, large objects, and JSON/transit columns |
| **Auth** | `app.auth` — Argon2id password hashing (buddy); session and token handling |
| **RPC** | `app.rpc.commands.*` — the command layer; each command is a `sv/defmethod` |
| **Email** | `app.email` — Jakarta Mail SMTP, template-based, sent asynchronously through the worker |
| **Worker** | `app.worker.*` — cron scheduler, task dispatcher (Valkey + Postgres), and a thread-pool executor |
| **Storage** | object storage abstraction with filesystem and S3 backends |
| **Audit** | `app.loggers.audit` — audit-event capture and archival |

> [!NOTE]
> Because the backend and frontend are written in Clojure/ClojureScript, static
> analyzers that target JS/TS/Rust won't surface their API. The authoritative
> configuration surface lives in the `app.config` schema, and the authoritative
> API surface lives in the `app.rpc.commands.*` namespaces — both documented
> below.

### 2.4 Data & collaboration flow

1. A browser loads the SPA from the frontend (nginx).
2. The SPA calls the backend's RPC API for queries and mutations.
3. Real-time edits are broadcast through Valkey pub/sub to other connected
   clients (multiplayer).
4. Long-running work (email, exports, file cleanup, snapshots) is enqueued as
   **tasks** in PostgreSQL; the worker dispatches them via Valkey to executor
   threads.
5. Assets (images, fonts) are written to object storage (filesystem volume or
   S3) and referenced from the database.

---

## 3. Requirements

| Component | Requirement |
|-----------|-------------|
| **Container runtime** | Docker Engine with the Compose plugin (Podman support is requested but not official) |
| **PostgreSQL** | Version **15** (the official stack pins `postgres:15`) |
| **Valkey** | Version **8.1** (Redis-compatible; "redis" naming retained in URIs) |
| **Object storage** | A persistent volume (filesystem backend) or S3-compatible service |
| **SMTP** | Required for email verification, invitations, and recovery in production |
| **CPU/RAM** | The JVM backend defaults to `-Xmx1000m`; size the host for your team and concurrency. Valkey defaults to a 128 MB max-memory cap |
| **TLS** | A reverse proxy (e.g. Traefik) for HTTPS in production |

> [!IMPORTANT]
> PostgreSQL is initialized with data checksums (`--data-checksums`). When you
> upgrade Postgres major versions, follow a proper dump/restore migration — the
> volume is pinned to the v15 data directory in the official compose file.

---

## 4. Deployment

### 4.1 Quick deploy (Docker Compose)

The official stack lives at `docker/images/docker-compose.yaml`. It defines
seven services: `penpot-frontend`, `penpot-backend`, `penpot-mcp`,
`penpot-exporter`, `penpot-postgres`, `penpot-valkey`, and `penpot-mailcatch`.

```bash
# 1. Get the official compose file
wget https://raw.githubusercontent.com/penpot/penpot/main/docker/images/docker-compose.yaml

# 2. (Recommended) pin a version
export PENPOT_VERSION=2.15

# 3. Start
docker compose -p penpot -f docker-compose.yaml up -d

# 4. Watch logs
docker compose -p penpot -f docker-compose.yaml logs -f penpot-backend
```

Open `http://localhost:9001`.

The frontend publishes container port `8080` on host port `9001`:

```yaml
penpot-frontend:
  image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}"
  ports:
    - 9001:8080
```

### 4.2 Service images

| Service | Image |
|---------|-------|
| frontend | `penpotapp/frontend:${PENPOT_VERSION}` |
| backend | `penpotapp/backend:${PENPOT_VERSION}` |
| exporter | `penpotapp/exporter:${PENPOT_VERSION}` |
| mcp | `penpotapp/mcp:${PENPOT_VERSION}` |
| postgres | `postgres:15` |
| valkey | `valkey/valkey:8.1` |
| mailcatch (dev only) | `sj26/mailcatcher:latest` |

### 4.3 Production checklist

- [ ] Generate a strong `PENPOT_SECRET_KEY`:
      `python3 -c "import secrets; print(secrets.token_urlsafe(64))"`
- [ ] Set `PENPOT_PUBLIC_URI` to your real HTTPS URL.
- [ ] Put Penpot behind TLS (Traefik example labels are included, commented, in
      the compose file).
- [ ] Remove `disable-secure-session-cookies` and `disable-email-verification`
      from `PENPOT_FLAGS`.
- [ ] Configure real SMTP (`PENPOT_SMTP_*`) and remove the mailcatch service.
- [ ] Set strong, non-default PostgreSQL credentials.
- [ ] Decide telemetry: `PENPOT_TELEMETRY_ENABLED` defaults to `true`.
- [ ] Provision durable storage for `penpot_assets` and `penpot_postgres_v15`.
- [ ] If you don't want AI access, remove `enable-mcp` from flags **and** don't
      run the `penpot-mcp` service.

### 4.4 Behind a reverse proxy (Traefik)

The compose file includes commented Traefik service and label examples. The
essential idea:

```yaml
# On penpot-frontend:
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.penpot-https.rule=Host(`design.example.com`)"
  - "traefik.http.routers.penpot-https.entrypoints=websecure"
  - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt"
  - "traefik.http.routers.penpot-https.tls=true"
```

```text
PENPOT_PUBLIC_URI: https://design.example.com
```

> [!WARNING]
> If `PENPOT_PUBLIC_URI` uses plain `http` on a non-`localhost` host, the
> backend automatically disables secure session cookies (so the app still
> works), which is **not safe for production**. Always serve production over
> HTTPS.

### 4.5 TrueNAS / appliance note

A reported install failure on a fresh TrueNAS install came from the public URI
ending up with a **duplicated domain segment**. When configuring on appliances
that auto-compose the URL, verify `PENPOT_PUBLIC_URI` is exactly one
`scheme://host[:port]` with no repetition.

---

## 5. Configuration Mastery

All backend configuration is read from environment variables. The backend reads
variables with the `PENPOT_` prefix, lowercases and strips the prefix, and maps
them to config keys (e.g. `PENPOT_DATABASE_URI` → `:database-uri`). Values are
decoded and validated against a Malli schema at startup; an invalid config
**aborts startup**.

Feature toggles are different: they live in the single space-separated
`PENPOT_FLAGS` string (e.g. `enable-smtp disable-email-verification enable-mcp`),
where each flag is `enable-X` or `disable-X`.

### 5.1 Core / networking

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_SECRET_KEY` | *(insecure placeholder)* | Master key; all subsystem keys derive from it. **Must change.** |
| `PENPOT_PUBLIC_URI` | `http://localhost:3449` | The public URL of the instance |
| `PENPOT_FLAGS` | — | Space-separated feature flags (see §5.9) |
| `PENPOT_HOST` | `localhost` | Host identity |
| `PENPOT_TENANT` | `default` | Tenant name (used in worker queue routing) |
| `PENPOT_HTTP_SERVER_PORT` | — | Backend HTTP port |
| `PENPOT_HTTP_SERVER_HOST` | — | Backend bind host |
| `PENPOT_HTTP_SERVER_MAX_BODY_SIZE` | `367001600` (≈350 MB) | Max request body size |
| `PENPOT_HTTP_SERVER_IO_THREADS` | — | HTTP IO thread count |
| `PENPOT_HTTP_SERVER_MAX_WORKER_THREADS` | — | HTTP worker thread count |
| `PENPOT_ALLOWED_ORIGINS` | — | CORS allowlist (with the `cors` flag) |
| `PENPOT_DISABLE_IPV6_LISTEN` | — | Set `true` on hosts with IPv6 disabled at boot |

### 5.2 Database

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_DATABASE_URI` | `postgresql://postgres/penpot` | JDBC/Postgres URI |
| `PENPOT_DATABASE_USERNAME` | `penpot` | DB user |
| `PENPOT_DATABASE_PASSWORD` | `penpot` | DB password |
| `PENPOT_DATABASE_READONLY` | `false` | Read-only mode (disables workers) |
| `PENPOT_DATABASE_MIN_POOL_SIZE` | — | Min HikariCP pool size |
| `PENPOT_DATABASE_MAX_POOL_SIZE` | `60` (pool default) | Max HikariCP pool size |

> [!NOTE]
> When the database is read-only, the cron scheduler and worker dispatcher do
> not start. Use read-only mode deliberately (e.g. maintenance), not by accident.

### 5.3 Cache / queue (Valkey/Redis)

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_REDIS_URI` | `redis://redis/0` | Valkey/Redis URI for pub/sub + task queue |

### 5.4 Object storage (assets)

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_OBJECTS_STORAGE_BACKEND` | `fs` | `fs` (filesystem) or `s3` |
| `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY` | `assets` | Directory for the `fs` backend |
| `PENPOT_OBJECTS_STORAGE_S3_BUCKET` | — | S3 bucket name |
| `PENPOT_OBJECTS_STORAGE_S3_REGION` | — | S3 region |
| `PENPOT_OBJECTS_STORAGE_S3_ENDPOINT` | — | S3 endpoint (for S3-compatible stores) |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | — | S3 credentials |

> [!CAUTION]
> The keys prefixed `PENPOT_ASSETS_STORAGE_*` / `PENPOT_STORAGE_ASSETS_*` are
> **deprecated** in favor of `PENPOT_OBJECTS_STORAGE_*`. Use the `OBJECTS`
> variants for new deployments.

### 5.5 Email (SMTP)

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_SMTP_HOST` | — | SMTP host |
| `PENPOT_SMTP_PORT` | — | SMTP port |
| `PENPOT_SMTP_USERNAME` | — | SMTP user (enables auth when set) |
| `PENPOT_SMTP_PASSWORD` | — | SMTP password |
| `PENPOT_SMTP_TLS` | `false` | STARTTLS |
| `PENPOT_SMTP_SSL` | `false` | Implicit SSL (`smtps`) |
| `PENPOT_SMTP_DEFAULT_FROM` | `Penpot <[email removed]>` | Default From |
| `PENPOT_SMTP_DEFAULT_REPLY_TO` | `Penpot <[email removed]>` | Default Reply-To |

Enable sending with the `smtp` flag; `smtp-debug` adds mail debug logging and
`log-emails` logs rendered emails.

### 5.6 Authentication & SSO

**Password auth** uses Argon2id (32 MiB memory, 3 iterations, parallelism 2).

| Provider | Key variables |
|----------|---------------|
| **Google** | `PENPOT_GOOGLE_CLIENT_ID`, `PENPOT_GOOGLE_CLIENT_SECRET` |
| **GitHub** | `PENPOT_GITHUB_CLIENT_ID`, `PENPOT_GITHUB_CLIENT_SECRET` |
| **GitLab** | `PENPOT_GITLAB_BASE_URI`, `PENPOT_GITLAB_CLIENT_ID`, `PENPOT_GITLAB_CLIENT_SECRET` |
| **OIDC** | `PENPOT_OIDC_BASE_URI`, `PENPOT_OIDC_CLIENT_ID`, `PENPOT_OIDC_CLIENT_SECRET`, `PENPOT_OIDC_AUTH_URI`, `PENPOT_OIDC_TOKEN_URI`, `PENPOT_OIDC_USER_URI`, `PENPOT_OIDC_JWKS_URI`, `PENPOT_OIDC_SCOPES`, `PENPOT_OIDC_ROLES`, `PENPOT_OIDC_ROLES_ATTR`, `PENPOT_OIDC_EMAIL_ATTR`, `PENPOT_OIDC_NAME_ATTR`, `PENPOT_OIDC_USER_INFO_SOURCE` (`auto`/`userinfo`/`token`) |
| **LDAP** | `PENPOT_LDAP_HOST`, `PENPOT_LDAP_PORT`, `PENPOT_LDAP_SSL`, `PENPOT_LDAP_STARTTLS`, `PENPOT_LDAP_BASE_DN`, `PENPOT_LDAP_BIND_DN`, `PENPOT_LDAP_BIND_PASSWORD`, `PENPOT_LDAP_USER_QUERY` (default `(\|(uid=:username)(mail=:username))`), `PENPOT_LDAP_ATTRS_USERNAME` (`uid`), `PENPOT_LDAP_ATTRS_EMAIL` (`mail`), `PENPOT_LDAP_ATTRS_FULLNAME` (`cn`) |

Each provider also needs its `login-with-*` flag enabled. Registration controls:

| Variable | Purpose |
|----------|---------|
| `PENPOT_REGISTRATION_DOMAIN_WHITELIST` | Restrict sign-up to listed email domains |
| `PENPOT_EMAIL_DOMAIN_WHITELIST` / `PENPOT_EMAIL_DOMAIN_BLACKLIST` | Domain allow/deny files |
| `PENPOT_EMAIL_VERIFY_THRESHOLD` | `15m` — cooldown after profile change before re-sending verification |
| `PENPOT_AUTH_TOKEN_COOKIE_NAME` | `auth-token` |
| `PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE` | Session cookie lifetime |

### 5.7 Quotas (abuse / capacity limits)

Penpot enforces per-team and per-profile quotas. All are `PENPOT_QUOTES_*`:

| Variable | Limits |
|----------|--------|
| `PENPOT_QUOTES_TEAMS_PER_PROFILE` | Teams a user can own |
| `PENPOT_QUOTES_PROFILES_PER_TEAM` | Members per team |
| `PENPOT_QUOTES_PROJECTS_PER_TEAM` | Projects per team |
| `PENPOT_QUOTES_FILES_PER_PROJECT` / `PENPOT_QUOTES_FILES_PER_TEAM` | Files per project / team |
| `PENPOT_QUOTES_INVITATIONS_PER_TEAM` | Pending invitations |
| `PENPOT_QUOTES_ACCESS_TOKENS_PER_PROFILE` | API/MCP access tokens |
| `PENPOT_QUOTES_FONT_VARIANTS_PER_TEAM` | Custom font variants |
| `PENPOT_QUOTES_COMMENT_THREADS_PER_FILE` / `PENPOT_QUOTES_COMMENTS_PER_FILE` | Comments |
| `PENPOT_QUOTES_SNAPSHOTS_PER_FILE` / `PENPOT_QUOTES_SNAPSHOTS_PER_TEAM` | File snapshots |
| `PENPOT_QUOTES_TEAM_ACCESS_REQUESTS_PER_TEAM` / `..._PER_REQUESTER` | Access requests |
| `PENPOT_QUOTES_UPLOAD_SESSIONS_PER_PROFILE` (5) / `PENPOT_QUOTES_UPLOAD_CHUNKS_PER_SESSION` (20) | Chunked upload limits |

### 5.8 Media, files, workers, and limits

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_MEDIA_MAX_FILE_SIZE` | 30 MiB | Max uploaded media size |
| `PENPOT_FONT_MAX_FILE_SIZE` | 30 MiB | Max uploaded font size |
| `PENPOT_DEFAULT_BLOB_VERSION` | `4` | File blob encoding version |
| `PENPOT_FILE_DATA_BACKEND` | `legacy-db` | File data storage: `db`, `legacy-db`, or `storage` |
| `PENPOT_AUTO_FILE_SNAPSHOT_EVERY` | `5` | Auto-snapshot frequency |
| `PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT` | `3h` | Auto-snapshot timeout window |
| `PENPOT_DELETION_DELAY` | 7 days | Grace period before hard deletion |
| `PENPOT_FILE_CLEAN_DELAY` | 2 days | Delay before file cleanup |
| `PENPOT_WORKER_DEFAULT_PARALLELISM` | — | Default worker concurrency |
| `PENPOT_WORKER_WEBHOOK_PARALLELISM` | — | Webhook worker concurrency |
| `PENPOT_RPC_RLIMIT_CONFIG` | `resources/rlimit.edn` | Rate-limit config file |
| `PENPOT_RPC_CLIMIT_CONFIG` | `resources/climit.edn` | Concurrency-limit config file |

### 5.9 Feature flags (`PENPOT_FLAGS`)

Set as a single space-separated string. Common flags:

| Flag | Effect |
|------|--------|
| `enable-smtp` / `disable-smtp` | Toggle real email sending |
| `enable-registration` / `disable-registration` | Allow new sign-ups |
| `enable-login-with-password` | Allow email+password login |
| `enable-login-with-google` / `-github` / `-gitlab` / `-oidc` / `-ldap` | Toggle each SSO provider |
| `enable-email-verification` / `disable-email-verification` | Require verified email |
| `enable-secure-session-cookies` / `disable-secure-session-cookies` | Secure cookie flag |
| `enable-demo-users` | Allow throwaway demo accounts |
| `enable-telemetry` | Send anonymous telemetry (also driven by `PENPOT_TELEMETRY_ENABLED`) |
| `enable-webhooks` | Enable outgoing webhooks |
| `enable-prepl-server` | Enable the pREPL server (introspection) |
| `enable-mcp` | Enable the MCP integration |
| `enable-cors` | Enable CORS with `PENPOT_ALLOWED_ORIGINS` |
| `enable-smtp-debug` / `enable-log-emails` / `enable-log-invitation-tokens` | Debugging aids |

Example production-ish value:

```text
PENPOT_FLAGS: enable-login-with-password enable-registration enable-smtp enable-email-verification enable-secure-session-cookies
```

### 5.10 Telemetry, audit & operational keys

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_TELEMETRY_ENABLED` | `true` | Anonymous usage telemetry |
| `PENPOT_TELEMETRY_URI` | `https://telemetry.penpot.app/` | Telemetry endpoint |
| `PENPOT_AUDIT_LOG_ARCHIVE_URI` | — | Where to archive audit logs |
| `PENPOT_AUDIT_LOG_HTTP_HANDLER_CONCURRENCY` | — | Audit ingest concurrency |
| `PENPOT_ERROR_REPORT_WEBHOOK` | — | Webhook for error reports |
| `PENPOT_USER_FEEDBACK_DESTINATION` | — | Destination for in-app feedback |
| `PENPOT_ADMINS` | — | Set of admin emails |
| `PENPOT_MANAGEMENT_API_KEY` | — | Key for management endpoints |
| `PENPOT_EXPORTER_SHARED_KEY` | — | Shared secret between backend and exporter |

### 5.11 SSRF protection

| Variable | Default | Purpose |
|----------|---------|---------|
| `PENPOT_SSRF_ALLOWED_HOSTS` | empty set | Hosts explicitly allowed for server-side fetches |
| `PENPOT_SSRF_EXTRA_BLOCKED_CIDRS` | empty set | Additional CIDR ranges to block |

### 5.12 MCP server variables

The MCP service is configured with its own variables:

| Variable | Purpose |
|----------|---------|
| `PENPOT_MCP_SERVER_HOST` / `PENPOT_MCP_SERVER_PORT` | MCP server bind address |
| `PENPOT_MCP_WEBSOCKET_PORT` | WebSocket port for the plugin bridge |
| `PENPOT_MCP_REPL_PORT` | REPL port for testing plugin API calls |
| `PENPOT_MCP_REMOTE_MODE` | Remote (multi-user) mode toggle |
| `PENPOT_MCP_LOG_LEVEL` / `PENPOT_MCP_LOG_DIR` | Logging |
| `PENPOT_LOGGERS_LOKI_URI` / `_JOB` / `_ENVIRONMENT` / `_INSTANCE` | Optional Loki log shipping |

---

## 6. Security

### 6.1 Secrets

- **`PENPOT_SECRET_KEY` is the root of trust.** Keys for sessions, invitations,
  and other subsystems are derived from it. Generate a high-entropy value
  (512-bit, base64/url-safe) and keep it secret and stable. Rotating it
  invalidates derived tokens/sessions.

  ```bash
  python3 -c "import secrets; print(secrets.token_urlsafe(64))"
  ```

- Set strong PostgreSQL credentials; never ship the `penpot/penpot` defaults to
  production.
- The `PENPOT_EXPORTER_SHARED_KEY` and `PENPOT_MANAGEMENT_API_KEY` must be set
  to non-guessable values where those features are used.

### 6.2 Authentication

- Passwords are hashed with **Argon2id** (memory-hard) — strong by default.
- Enforce **email verification** in production
  (`enable-email-verification`).
- Prefer SSO (Google/GitHub/GitLab/OIDC/LDAP) for centralized account control.
- Use `PENPOT_REGISTRATION_DOMAIN_WHITELIST` and email domain allow/deny lists
  to constrain who can register.

### 6.3 Sessions & transport

- Always serve over **HTTPS** and keep `enable-secure-session-cookies` on.
- Remember: an `http` non-localhost `PENPOT_PUBLIC_URI` auto-disables secure
  cookies — a convenience that is unsafe for production.
- Constrain CORS with `enable-cors` + `PENPOT_ALLOWED_ORIGINS` rather than
  allowing all origins.

### 6.4 SSRF and outbound requests

Penpot includes **SSRF protection** for server-side fetches (e.g. importing
media from a URL). Tune `PENPOT_SSRF_ALLOWED_HOSTS` and
`PENPOT_SSRF_EXTRA_BLOCKED_CIDRS` to your environment; don't broaden the
allowlist beyond what you need.

### 6.5 Access tokens & API surface

- **Access tokens** let external tools (including MCP clients) act as a user.
  Treat them as credentials; cap them with
  `PENPOT_QUOTES_ACCESS_TOKENS_PER_PROFILE` and revoke unused tokens.
- Limit the blast radius of the MCP integration: only enable `enable-mcp` and
  run the `penpot-mcp` service where you actually need AI access.

> [!CAUTION]
> Known issue: the `penpot-mcp` service may run even when `disable-mcp` is in
> flags. If MCP must be fully off, do **not** start the `penpot-mcp` container.

### 6.6 Abuse limits

- Rate limiting (`rlimit.edn`) and concurrency limiting (`climit.edn`) protect
  the RPC layer; keep them enabled.
- Quotas (§5.7) protect against runaway resource use per team/profile.
- Chunked-upload limits cap upload sessions and chunks per session.

### 6.7 Telemetry & privacy

`PENPOT_TELEMETRY_ENABLED` defaults to `true` and sends anonymous instance data
to Penpot. Set it to `false` if your policy forbids outbound telemetry. The
data sent is auditable in the source.

---

## 7. Operations

### 7.1 Start, stop, logs

```bash
# Start / stop the whole stack
docker compose -p penpot up -d
docker compose -p penpot down

# Tail logs for a service
docker compose -p penpot logs -f penpot-backend

# Restart one service after a config change
docker compose -p penpot restart penpot-backend
```

> [!NOTE]
> Configuration is read at startup. After changing any `PENPOT_*` variable or a
> flag, **restart** the affected service.

### 7.2 The background worker

The backend runs a worker subsystem with three parts:

- **Cron** (`app.worker.cron`) — registers scheduled tasks in the
  `scheduled_task` table and runs them on their cron schedule, using
  `FOR UPDATE SKIP LOCKED` so only one node runs a given task at a time.
- **Dispatcher** (`app.worker.dispatcher`) — polls the `task` table for due
  tasks (`new`/`retry`), pushes them onto Valkey queues per `tenant`, and marks
  them scheduled. It also **reschedules lost tasks** (e.g. after a Valkey
  restart) and **fails orphaned tasks** that have been `running` for over 24h.
- **Executor** (`app.worker.executor`) — a thread-pool (with Netty event-loop
  groups for IO) that runs task handlers; each task has `max-retries`,
  `priority`, and a status of `new`/`retry`/`scheduled`/`running`/`completed`/
  `failed`.

Tasks include email sending, exports, file cleanup, and snapshotting. Tune
concurrency with `PENPOT_WORKER_DEFAULT_PARALLELISM` and
`PENPOT_WORKER_WEBHOOK_PARALLELISM`.

> [!IMPORTANT]
> Workers do **not** start when the database is read-only. If background work
> (emails, exports) stops, confirm the DB isn't in read-only mode and that
> Valkey is reachable.

### 7.3 Backups

Back up two things:

1. **PostgreSQL** — the system of record.
   ```bash
   docker exec penpot-penpot-postgres-1 \
     pg_dump -U penpot penpot | gzip > penpot-db-$(date +%F).sql.gz
   ```
2. **Object storage** — the `penpot_assets` volume (or your S3 bucket).
   ```bash
   docker run --rm -v penpot_penpot_assets:/data -v "$PWD":/backup alpine \
     tar czf /backup/penpot-assets-$(date +%F).tgz -C /data .
   ```

For individual files, designers/admins can also export `.penpot` files.

### 7.4 Restore

- Restore Postgres from the dump into a fresh `postgres:15` data directory.
- Restore the assets volume/bucket.
- Bring the stack up; the backend runs schema migrations automatically on start.

### 7.5 Upgrades

1. Read the release notes for the target `PENPOT_VERSION`.
2. **Back up** the database and assets first.
3. Bump `PENPOT_VERSION` and `docker compose pull`.
4. `docker compose up -d` — the backend applies any new migrations on startup.
5. Verify health and check backend logs for migration output.

> [!WARNING]
> Don't skip the backup step on upgrades. Migrations run automatically and are
> not trivially reversible.

### 7.6 Health & monitoring

- Watch backend logs for connection-pool, Valkey, and migration messages.
- HikariCP exposes pool statistics (active/idle/total connections); Prometheus
  metrics are wired through the metrics subsystem when enabled.
- Monitor the `task` table for growing `failed`/`new` backlogs (worker health)
  and for orphan-cleanup warnings in logs.

### 7.7 Scaling

- The frontend and exporter scale horizontally (stateless).
- The backend can run multiple instances sharing Postgres + Valkey; worker
  task claiming is safe across nodes (advisory locks / `SKIP LOCKED`).
- Move assets to **S3** when running multiple backend nodes so all nodes share
  storage.
- Size the JVM heap (`JAVA_OPTS`, default `-Xmx1000m -Xms50m`) and the DB pool
  (`PENPOT_DATABASE_MAX_POOL_SIZE`, default 60) for your load.

---

## 8. Building From Source (Developers)

Penpot uses a Docker-based development environment ("devenv") orchestrated by
`manage.sh` at the repo root.

### 8.1 The devenv

```bash
# Pull (or build) the devenv image, then bring up a workspace
./manage.sh pull-devenv
./manage.sh run-devenv          # ws0 = the live repo

# AI-assisted development (enables MCP + Serena)
./manage.sh run-devenv --agentic

# Attach to the running tmux session
./manage.sh attach-devenv
```

`manage.sh` supports **parallel workspaces** (`ws0`–`ws5`) so you can run
multiple isolated instances, each with its own port block (stride 10000):

```bash
./manage.sh run-devenv --ws 1 --sync     # seed and start a second workspace
./manage.sh stop-devenv --all            # stop everything
```

It can also launch a coding agent against a workspace's generated MCP config:

```bash
./manage.sh start-coding-agent claude          # or opencode | vscode | codex
```

### 8.2 Building release artifacts

Each module builds into a bundle and then a Docker image:

```bash
# Build a module bundle inside the devenv image
./manage.sh build-backend-bundle
./manage.sh build-frontend-bundle
./manage.sh build-exporter-bundle
./manage.sh build-mcp-bundle

# Package a bundle into a release image
./manage.sh build-backend-docker-image
./manage.sh build-frontend-docker-image
```

The backend builds an **uberjar** (`clojure.tools.build`); the frontend builds
with **shadow-cljs** and bundles the **render-wasm** (`build:wasm`); the render
engine is a Rust crate built with `wasm-pack`/cargo (Skia via `skia-safe`).

### 8.3 Module map

| Path | Module | Stack |
|------|--------|-------|
| `backend/` | Core API, RPC, workers | Clojure (JVM) |
| `common/` | Shared domain code | Clojure/CLJC |
| `frontend/` | SPA | ClojureScript + React |
| `frontend/text-editor/` | Rich text editor | JS |
| `render-wasm/` | Canvas renderer | Rust + Skia → WASM |
| `exporter/` | Export service | ClojureScript on Node + Playwright |
| `mcp/` | MCP server | TypeScript on Node |
| `plugins/` | Plugin SDK + example plugins | TypeScript / Angular |
| `library/` | `@penpot/library` tooling | ClojureScript |
| `docs/` | Documentation site | Eleventy (11ty) |

### 8.4 Testing & quality

| Module | Test command |
|--------|--------------|
| backend | `clojure -M:dev:test` |
| frontend | `pnpm test` (builds WASM + CLJS, runs with Node) |
| frontend e2e | `pnpm test:e2e` (Playwright) |
| plugins | `pnpm test` / `pnpm test:e2e` |
| common | `pnpm run test:jvm` / `test:js` |

Linting/formatting use `clj-kondo` and `cljfmt` for Clojure and `prettier` /
`eslint` / `stylelint` for JS/SCSS.

---

## 9. The RPC API (Internals)

The backend's API is a **command layer**: each operation is a `sv/defmethod`
under `app.rpc.commands.*`, dispatched by a namespaced keyword. Clients call
these as queries (reads) or mutations (writes). The command surface is grouped
by domain:

| Namespace | Representative commands |
|-----------|-------------------------|
| `auth` | `login-with-password`, `logout`, `register-profile`, `prepare-register-profile`, `recover-profile`, `request-profile-recovery`, `get-sso-provider` |
| `access-token` | `create-access-token`, `delete-access-token`, `get-access-tokens`, `get-current-mcp-token` |
| `profile` | `get-profile`, `update-profile`, `update-profile-password`, `update-profile-photo`, `request-email-change`, `delete-profile`, `get-subscription-usage` |
| `teams` | `get-teams`, `create-team`, `update-team`, `leave-team`, `delete-team`, `get-team-members`, `update-team-member-role`, `get-team-stats` |
| `teams-invitations` | `create-team-invitations`, `create-team-with-invitations`, `get-team-invitation-token`, `delete-team-invitation`, `create-team-access-request` |
| `projects` | `get-projects`, `create-project`, `rename-project`, `update-project-pin`, `delete-project` |
| `files` | `get-file`, `get-page`, `create-file` (in `files-create`), `rename-file`, `set-file-shared`, `delete-file`, `link-file-to-library`, `get-file-libraries`, `get-library-usage` |
| `files-update` | `update-file` (the core mutation that applies design changes) |
| `files-snapshot` | `create-file-snapshot`, `restore-file-snapshot`, `lock/unlock-file-snapshot`, `get-file-snapshots` |
| `files-share` | `create-share-link`, `delete-share-link` |
| `files-thumbnails` | `create-file-thumbnail`, `get-file-object-thumbnails`, `get-file-data-for-thumbnail` |
| `comments` | `create-comment-thread`, `create-comment`, `get-comment-threads`, `update-comment-thread-status`, `mark-all-threads-as-read` |
| `fonts` | `create-font-variant`, `get-font-variants`, `update-font`, `delete-font-variant`, `download-font-family` |
| `media` | `upload-file-media-object`, `create-upload-session`, `upload-chunk`, `assemble-file-media-object`, `create-file-media-object-from-url` |
| `binfile` | `export-binfile`, `import-binfile` (the `.penpot` format) |
| `management` | `duplicate-file`, `duplicate-project`, `move-files`, `move-project`, `clone-template`, `get-builtin-templates` |
| `search` | `search-files` |
| `viewer` | `get-view-only-bundle` |
| `webhooks` | `create-webhook`, `update-webhook`, `delete-webhook` |
| `ldap` | `login-with-ldap` |
| `nitrate` | subscription/organization commands (`redeem-nitrate-activation-code`, `add-team-to-organization`, `leave-org`, …) |
| `audit` | `push-audit-events`, `get-enabled-flags` |
| `verify-token` | `verify-token` (email/invitation/recovery verification) |
| `feedback` | `send-user-feedback` |

The MCP server's HTTP surface (separate from RPC) is small:

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/sse` | MCP event stream |
| `POST` | `/messages` | MCP message channel |
| `GET` | `/` | REPL UI (plugin API testing) |
| `POST` | `/execute` | REPL execution endpoint |

---

## 10. Extensibility (Plugins & MCP)

### 10.1 Plugin architecture

- **`@penpot/plugin-types`** — the published TypeScript type definitions for the
  whole plugin API (the `Penpot`/`Context` interface). This is the authoritative
  surface for what plugins can do: create/manipulate shapes, work with boards,
  pages, fills, strokes, text, **tokens**, **libraries**, comments, fonts,
  history, and the viewer; persist plugin data; and subscribe to editor events.
- **`@penpot/plugins-runtime`** — loads plugins and generates the API. It runs
  plugin code in a **sandbox** (using SES — Secure EcmaScript) and validates
  manifests (`zod`). It rejects invalid manifest versions and enforces granted
  permissions (raising `Permission <name> is not granted`).
- **`@penpot/plugin-styles`** — optional CSS to match Penpot's UI.

A plugin is a web app + a **manifest** (name, code entry, permissions). The
editor loads it by manifest URL and prompts the user to grant permissions.

### 10.2 MCP server

The MCP server (`mcp/packages/server`) is a Node/TypeScript app built on
`@modelcontextprotocol/sdk` with `express` and `ws`. It:

- Discovers and **registers tools** dynamically (each tool exposes a name,
  description, and input schema) and wires them to the running Penpot instance
  via a **plugin bridge**.
- Supports **single-user** and **multi-user/remote** modes
  (`--multi-user` / `PENPOT_MCP_REMOTE_MODE`); multi-user mode requires a user
  token in the session context.
- Includes a **REPL** for testing plugin API calls interactively.

Run it standalone for development:

```bash
cd mcp
pnpm install
pnpm run build
pnpm run start                 # or: pnpm run start:multi-user
```

> [!NOTE]
> In production the MCP server runs as the `penpot-mcp` container and is gated by
> the `enable-mcp` flag plus access tokens. See §6.5 for the security caveats.

---

## 11. Troubleshooting (Operations)

### Error: Startup aborts with a configuration validation error

**Cause:** A `PENPOT_*` value fails the config schema (wrong type, malformed
URI, etc.).
**Solution:** Read the printed validation explanation, fix the offending
variable, and restart. The backend validates config at boot and exits on error.
**Prevention:** Change config in small steps and watch startup logs.

### Error: Backend can't connect to the database

**Cause:** Wrong `PENPOT_DATABASE_URI`/credentials, or Postgres not healthy yet.
**Solution:** Verify the URI and credentials; the compose file gates the backend
on Postgres health (`pg_isready`). Check `penpot-postgres` logs.
**Prevention:** Use the provided healthchecks and depends_on ordering.

### Symptom: Emails, exports, or webhooks never run

**Cause:** Worker not running — DB is read-only, or Valkey is unreachable, or
the relevant flag is off.
**Solution:** Confirm `PENPOT_DATABASE_READONLY` isn't set, Valkey is up
(`PENPOT_REDIS_URI`), and `enable-smtp` (for email) is set. Inspect the `task`
table and backend logs.
**Prevention:** Monitor the task backlog and worker log lines.

### Symptom: `reschedule` / `mark as orphan failed` warnings in logs

**Cause:** Normal recovery — the dispatcher reschedules in-transit tasks after a
Valkey blip and fails tasks stuck `running` for 24h.
**Solution:** Occasional entries are fine. A flood indicates Valkey instability
or stuck task handlers; investigate Valkey and the failing task type.
**Prevention:** Keep Valkey healthy and resourced.

### Symptom: Exports fail or hang

**Cause:** The exporter drives a headless browser (Playwright) that loads the
frontend; if it can't reach the frontend or lacks browser deps, exports fail.
**Solution:** Ensure the exporter can reach the frontend at its internal URI
(`PENPOT_PUBLIC_URI: http://penpot-frontend:8080` in the default stack) and
shares the secret key. Check exporter logs.
**Prevention:** Use the official exporter image (browser deps included).

### Symptom: Users behind a proxy get login/cookie issues

**Cause:** `PENPOT_PUBLIC_URI` mismatch or HTTP vs. secure-cookie conflict.
**Solution:** Set `PENPOT_PUBLIC_URI` to the exact external HTTPS URL and keep
`enable-secure-session-cookies` on.
**Prevention:** Finalize the URL/TLS before onboarding users.

### Symptom: Large uploads rejected

**Cause:** Body or media/font limits exceeded.
**Solution:** Raise `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`,
`PENPOT_MEDIA_MAX_FILE_SIZE`, and/or `PENPOT_FONT_MAX_FILE_SIZE`, and any proxy
body-size limits.
**Prevention:** Communicate upload limits to users.

---

## 12. Quick Reference

### Services & default ports

| Service | Image | Port |
|---------|-------|------|
| frontend | `penpotapp/frontend` | `9001:8080` |
| backend | `penpotapp/backend` | internal |
| exporter | `penpotapp/exporter` | internal |
| mcp | `penpotapp/mcp` | internal (`enable-mcp`) |
| postgres | `postgres:15` | internal |
| valkey | `valkey/valkey:8.1` | internal |
| mailcatch (dev) | `sj26/mailcatcher` | `1080` (web), `1025` (smtp) |

### Must-set production variables

`PENPOT_SECRET_KEY` · `PENPOT_PUBLIC_URI` · `PENPOT_DATABASE_*` ·
`PENPOT_REDIS_URI` · `PENPOT_FLAGS` · `PENPOT_SMTP_*` ·
`PENPOT_OBJECTS_STORAGE_*`

### Generate a secret key

```bash
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
```

### Essential operations

```bash
docker compose -p penpot up -d                    # start
docker compose -p penpot logs -f penpot-backend   # logs
docker compose -p penpot restart penpot-backend   # apply config change
docker exec penpot-penpot-postgres-1 pg_dump -U penpot penpot | gzip > db.sql.gz  # backup
```

### Dev/build (manage.sh)

```bash
./manage.sh run-devenv [--agentic] [--ws N]   # dev environment
./manage.sh build-<module>-bundle             # build a module
./manage.sh build-<module>-docker-image       # package a release image
```

### Stack summary

ClojureScript+React frontend · Clojure/JVM backend · Rust/Skia→WASM renderer ·
Node exporter (Playwright) · TS MCP server · PostgreSQL 15 · Valkey 8.1 ·
MPL-2.0

---

_Generated June 2026 from penpot/penpot at 8d3516d. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial; not affiliated with or reviewed by the project._
