Use case · Design and prototyping

Penpot self hosted: the compose stack, sign-up flags and backups

A self-hosted Penpot instance is the official Docker Compose stack of eight containers, and Penpot's guide puts the server at 4 CPUs and 16 GB of RAM, which it calls sufficient for thousands of users. Disk is the number that moves: Penpot suggests starting the database at 50 to 100 GB for up to 10 editors and adding about 5 GB per editor beyond that. Below: the stack service by service, an install with the web port on loopback and secrets in a mode-600 .env, the registration and OIDC flags as the docs actually rank them, nginx with the WebSocket paths, and backups that leave the box.

See the machines

What Penpot asks of the server

Penpot's recommended-settings page is short on hardware and specific where it speaks. Most of the work happens in the browser: Penpot tells you to give end users powerful computers and recommends the latest stable Chrome. The server holds the files, the database and the real-time notifications.

ResourceWhat Penpot's docs say
CPU and RAM4 CPUs and 16 GB of RAM are sufficient for thousands of users; you can be conservative
Database diskStart at 50-100 GB with room to grow for up to 10 editors; roughly 5 GB more per editor past 10
PostgreSQLThe only supported database; version 15 or newer highly recommended (the compose file ships postgres:15)
ValkeyCoordinates WebSocket notifications and, since 2.11, acts as a cache; in RAM only, capped at 128 MB in the compose file
SkillsDocker and Docker Compose, basic DNS management and proxy configuration

Database growth depends on file complexity more than head count, the docs add. Size the disk for the database plus every uploaded image and SVG.

The compose stack, service by service

ServiceRoleExposure in the stock file
penpot-frontendWeb app and the internal nginx that serves assetsPublished as 9001:8080 on every address
penpot-backendAPI, sessions and WebSocket notificationsInternal network only
penpot-exporterHeadless browser for exportsInternal
penpot-admin-consoleNew service in 2.18Internal
penpot-mcpMCP service; Penpot's nginx example proxies /mcp/wsInternal
penpot-postgrespostgres:15, volume penpot_postgres_v15Internal
penpot-valkeyvalkey 8.1, maxmemory 128mb, volatile-lfuInternal
penpot-mailpitTest mailbox that catches every email Penpot sendsWeb UI published as 8025:8025 on every address

Two lines in that file matter before anything is public. The frontend and the Mailpit inbox are both published on all interfaces, and Mailpit's inbox shows every verification and invitation link Penpot sends. The stock flags also carry disable-email-verification and disable-secure-session-cookies, and the file itself warns you to remove both when Penpot faces the internet. The secret key (change-this-insecure-key) and the database password (penpot) are placeholders.

Install: firewall, compose file, secrets

  1. Firewall first, SSH only: IPV6=yes goes in before any ufw allow, and --force keeps the prompt from eating the next pasted line.
  2. Set up the Compose plugin and Docker Engine, version 28 or later, per Docker's documentation; docker version shows the engine version.
  3. Fetch the compose file and write the secret key (Penpot's 512-bit generator) and database password to .env at mode 600.
  4. Bind both ports to 127.0.0.1, point the file at .env, drop the two flags it warns about, and start. Pinning PENPOT_VERSION is what the docs recommend.
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw allow OpenSSH
sudo ufw --force enable

# Docker Engine 28+ and the Compose plugin per docs.docker.com (check: docker version)
sudo mkdir -p /opt/penpot && cd /opt/penpot
sudo curl -o docker-compose.yaml https://raw.githubusercontent.com/penpot/penpot/main/docker/images/docker-compose.yaml
sudo install -m 600 /dev/null .env
sudo tee .env >/dev/null <<EOF
PENPOT_VERSION=2.18
PENPOT_PUBLIC_URI=https://design.example.com
PENPOT_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))")
PENPOT_DB_PASSWORD=$(openssl rand -hex 24)
EOF
sudo sed -i \
  -e 's|- 9001:8080|- "127.0.0.1:9001:8080"|' \
  -e 's|"8025:8025"|"127.0.0.1:8025:8025"|' \
  -e 's|PUBLIC_URI: http://localhost:9001|PUBLIC_URI: ${PENPOT_PUBLIC_URI}|' \
  -e 's|SECRET_KEY: change-this-insecure-key|SECRET_KEY: ${PENPOT_SECRET_KEY}|' \
  -e 's|DATABASE_PASSWORD: penpot|DATABASE_PASSWORD: ${PENPOT_DB_PASSWORD}|' \
  -e 's|POSTGRES_PASSWORD=penpot|POSTGRES_PASSWORD=${PENPOT_DB_PASSWORD}|' \
  -e 's| disable-email-verification||; s| disable-secure-session-cookies||' \
  docker-compose.yaml
sudo docker compose -p penpot -f docker-compose.yaml up -d

A port mapping with no address, like the stock 9001:8080 and "8025:8025", is published on 0.0.0.0 and [::], and that traffic skips ufw's rules (on IPv4 at least), so ufw deny 9001 would not close it. The fence is the 127.0.0.1 bind. On Docker Engine 28 and later that keeps the port local to the machine; older engines let hosts on the same network segment reach it (moby#45610), hence the version requirement. Set the database password before the first start: PostgreSQL only reads POSTGRES_PASSWORD when it creates an empty data volume.

PENPOT_PUBLIC_URI is the address users type, and Penpot's configuration page says it must point there; in the official file it is set once at the top and shared by the backend, frontend, exporter and admin console. Compose reads .env from the project folder, so the secrets stay out of the YAML and out of your shell history. The backend also ships with PENPOT_TELEMETRY_ENABLED: "true"; set it to "false" if you would rather not send Penpot anonymous instance data.

Registration, password login and OIDC

By default anyone who reaches the site can register with email and password. The docs do not tell you to switch that off: for production they want it paired with SMTP so sign-ups are verified by email, and they file disable-registration under demo instances.

SettingEffectDocs' position
(no flag)Email and password sign-up, verified by emailDefault; pair it with SMTP
disable-email-verificationAccounts work without confirming the addressIn the stock file; remove it before going public
PENPOT_REGISTRATION_DOMAIN_WHITELISTOnly listed email domains may sign upOffered for restricting sign-ups
enable-login-with-oidcSign-in through your identity providerRegister the callback https://design.example.com/api/auth/oidc/callback with the provider
enable-oidc-registrationFirst-time OIDC users get an account without another sign-upSince 2.1.2
disable-login-with-passwordHides the password formListed among the user flags
disable-registrationNobody can sign up; the CLI is then the only way to add usersRecommended only for demo instances

Flags live in one list, PENPOT_FLAGS, at the top of the compose file; the OIDC client ID, secret and base URI go in the backend's environment, and the secret belongs in .env like the others. An authentik or Keycloak instance is a natural provider. Whatever you choose, run docker compose ... up -d again to apply it.

Create the first account before the proxy opens anything. The docs' CLI, manage.py, needs enable-prepl-server, which the stock flags already carry: run sudo docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile and answer the prompts (check the container name with docker ps). Its -p option would put the password into the process arguments, readable in ps and kept in your shell history, so let it prompt.

Outgoing mail over SMTP

Until you change it, the backend sends mail to the bundled Mailpit container, which keeps messages in memory and is meant as a stopgap. Penpot's docs are blunt about why real mail matters: a person joins a team only by accepting an emailed invitation, and the docs offer no way around that today. Point the backend's SMTP block at your provider: PENPOT_SMTP_HOST, port 587, username, PENPOT_SMTP_TLS: "true", and PENPOT_SMTP_DEFAULT_FROM and PENPOT_SMTP_DEFAULT_REPLY_TO on a domain you send from. Put the password in .env and reference it as PENPOT_SMTP_PASSWORD: ${PENPOT_SMTP_PASSWORD}. To read Mailpit meanwhile, tunnel through your own login: ssh -L 8025:127.0.0.1:8025 you@server.

nginx in front, with the WebSocket paths

Penpot strongly recommends HTTPS, and some browser features, the clipboard among them, do not work without it. Its nginx example sends two paths as WebSocket upgrades, /ws/notifications for real-time collaboration and /mcp/ws for the MCP service, and sets the upload limit to match PENPOT_HTTP_SERVER_MAX_BODY_SIZE. The version below follows it, with localhost written as 127.0.0.1 to match the loopback bind and proxy_http_version 1.1, which nginx needs for an upgrade. Certificate paths assume certbot.

server {
  listen 80;
  server_name design.example.com;
  return 301 https://$host$request_uri;
}
server {
  listen 443 ssl;
  server_name design.example.com;
  ssl_certificate     /etc/letsencrypt/live/design.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/design.example.com/privkey.pem;
  client_max_body_size 367001600;

  location /ws/notifications {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_pass http://127.0.0.1:9001/ws/notifications;
  }
  location /mcp/ws {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_pass http://127.0.0.1:9001/mcp/ws;
  }
  location / {
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_redirect off;
    proxy_pass http://127.0.0.1:9001/;
  }
}

With the first profile created and the sign-up policy set, open the web ports with sudo ufw allow proto tcp to any port 80,443. Because IPV6=yes was set before the first rule, ufw writes the IPv6 copies too.

Backups and updates

Penpot keeps everything in Docker volumes, and its backup section says not to copy the volume folders directly: use a temporary container that mounts the volume and writes an archive. The stock file has two, the PostgreSQL data and the user assets (images and SVG clips). With project name penpot they are penpot_penpot_postgres_v15 and penpot_penpot_assets; confirm with docker volume ls. The database goes out as a pg_dump. Create the folder first with sudo install -d -m 700 /var/backups/penpot.

# /usr/local/sbin/penpot-backup.sh
set -eu
umask 077
D=/var/backups/penpot
T=$(date +%F)
cd /opt/penpot
docker compose -p penpot exec -T penpot-postgres pg_dump -U penpot -Fc penpot > "$D/db-$T.dump"
docker run --rm -v penpot_penpot_assets:/data:ro -v "$D":/backup ubuntu tar czf "/backup/assets-$T.tar.gz" -C /data .
find "$D" -type f -mtime +14 -delete

# /etc/cron.d/penpot-backup
30 2 * * * root umask 077; bash /usr/local/sbin/penpot-backup.sh > /var/backups/penpot/run-$(date +\%F).log 2>&1

The script uses exec -T, not -ti, because cron has no terminal. Pull /var/backups/penpot to another machine on a schedule you control, and now and then restore it onto a scratch host to prove it works.

To update, back up, change PENPOT_VERSION in .env, then run sudo docker compose -p penpot -f docker-compose.yaml pull and the same command with up -d. Keep -p penpot on every call so Compose always addresses the same project, and the same volumes. Penpot strongly recommends small steps rather than jumps between distant versions, and its release notes carry compose changes: 2.18 added the admin-console service. Because your file is edited, fetch the new official one, re-apply the same sed, and diff it against yours before you start it.

Which machine to put it on

Penpot's own sizing fits the small end of our dedicated server range. The cartridge line on the Xeon D-1587 (16 cores, 16-128 GB, 1.92 TB NVMe, 1 Gbps) covers the CPU and RAM figure with disk to spare for a growing team; the AMD Opteron 6366 HE single-socket line (16 cores, 16-256 GB) carries it with room for the identity provider and a Git server beside it, once you choose disks large enough for the database and assets. Size memory first, then disk for the database and assets.

  • The whole machine with root and no other tenant, so nothing competes with PostgreSQL for disk.
  • An out-of-band console on every racked single-, dual- and quad-socket machine, and two cartridge types as well (m510, m710x): a firewall mistake costs a console session, not a reinstall.
  • Unmetered ports: no transfer allowance to watch as designers upload and export.
  • Our own network, AS19624, in New York, Miami, San Francisco, Amsterdam and Bucharest; pick the city nearest the team.
  • Racked machines handed over in about 30 minutes once payment is approved; built-to-order lines take 4 to 24 hours. Month to month.

When not to self-host it

Penpot's own docs point people who are not comfortable with Docker, DNS and a proxy to its hosted service or a simpler managed installation instead. They also note that self-hosted images are published shortly after each update to the cloud version, so new features reach you later. If nobody on the team will apply updates in small steps and check the backups, the hosted service is the honest answer; the same reasoning applies to a self-hosted wiki.

Questions

How much hardware does a Penpot server need?

Penpot's recommended settings say 4 CPUs and 16 GB of RAM are sufficient for thousands of users. Disk depends on usage: start the database at 50-100 GB for up to 10 editors and add about 5 GB per editor beyond that, plus room for uploaded assets. Client machines matter as much, because most of the work runs in the browser.

Is there a default admin login?

No. The compose file creates no account; the first user either registers through the site or is created with manage.py create-profile inside the backend container. Create it before the site is reachable, and decide the sign-up policy (domain whitelist, OIDC or closed registration) at the same time.

Should I set disable-registration?

Penpot's configuration page recommends disable-registration only for demo instances. For production it wants password sign-up with SMTP and email verification, optionally limited with PENPOT_REGISTRATION_DOMAIN_WHITELIST; OIDC sign-in is the other documented route. If you close registration anyway, new users can only be added with the manage.py CLI.

What licence is Penpot under?

The Mozilla Public License 2.0 (MPL-2.0), per the LICENSE file in Penpot's GitHub repository. The self-hosting guide says the experience is the same as on Penpot's cloud, with images published shortly after each cloud update.

A whole machine for your design files

Pick a line with the memory and disk your team needs, install Ubuntu, and run the stack behind nginx.

See the machines

Related on Server Room

Other use cases