DockerSelf-HostingAI Coding Agents

Penpot with Docker: self-host the open-source Figma alternative (Linux, Windows, Mac)

Before you start

  • 22 min read

Boxes marked Run in terminal are what you type; plain text explains why. Check what you need first

What you get in one minute#

Penpot is a free, open-source design and prototyping tool that runs in the browser, and you can host it on your own server so your files, your team and your invitations stay with you.

Penpot official logo

Logo: the official Penpot project (github.com/penpot/penpot)

Designers and developers share a file in real time. Penpot uses open standards (SVG, CSS, HTML, JSON), so developers can read designs, and it has design tokens, components, flex and grid layouts, plugins and an optional MCP server for AI tools. This guide builds a small private team server: invite-only, with email, HTTPS and backups.

WhatValue (official docs, read November 2026)
Current versionPenpot 2.18.6, released 2026-11-04 (latest release on GitHub)
Images to pinpenpotapp/frontend, backend, exporter, admin-console and mcp, tag 2.18.6 (all five checked on Docker Hub; amd64 and arm64)
Time neededAbout 30 minutes plus the download (our estimate, not measured)
Download sizeAbout 1.5 GB compressed for the five Penpot images (sum of the Docker Hub sizes)
RAMNot published for small teams; see "Performance and resources"
Ports9001 (web app, we bind it to the server itself), 8025 (Mailpit inbox, local only)
LicenceMPL-2.0 (LICENSE at the 2.18.6 tag)
Official docshelp.penpot.app/technical-guide and the repository

Penpot's own promotional image: the workspace with layers, assets and tokens on the left, a mobile app design in the middle, and the code inspector on the right

Image: the official Penpot project (link-preview image in the repository at 2.18.6). It is a promotional picture, not a screenshot of this exact version.

At a glance:

  • What it is: a design tool like Figma, in your browser. Eight containers: web server, backend, exporter, admin console, MCP server, PostgreSQL, Valkey and Mailpit (test email).
  • Free or paid: the open-source edition is free. The docs describe a paid Enterprise plan with Organizations, an Admin Console and SSO modules.
  • Platforms: the server runs in Docker (amd64 and arm64 images). Designers need a browser; the docs recommend current Chrome.
  • Effort: the docs say you need Docker, DNS and proxy knowledge. For a quick try, use the free cloud service.
  • Privacy: the example file turns telemetry on. We turn it off.

How it fits together#

Designers reach one address. Behind it, a web container passes requests to the backend, which stores users and files in PostgreSQL and images in one volume, and sends invitation emails.

Diagram with three columns: designers in a browser go over HTTPS through an optional Caddy or Traefik proxy to penpot-frontend on port 8080, then to penpot-backend and PostgreSQL 15; the backend also writes uploaded files to an assets volume and sends invitation emails to an SMTP server

Drawn for this guide from the official compose file and docs. The Valkey cache, exporter, admin console and MCP containers are left out to keep it readable. The dashed step is the optional HTTPS proxy.

Before you start#

Penpot's docs ask for Docker and Docker Compose plus basic DNS and proxy knowledge, and give no per-system support list. A team server belongs on Linux; Windows and Mac work for trying it out (we did not test them).

SystemWhat you need
Linux (server)Docker Engine with the Compose plugin, amd64 or arm64, curl, openssl. The docs say 4 CPUs and 16 GB RAM serve thousands of users; no figure is published for small teams
WindowsDocker Desktop and PowerShell
MacDocker Desktop and Terminal (arm64 images exist)
DesignersA current browser. The docs recommend the latest Chrome and say the browser does heavy work, so their computers matter

Check Docker (all systems):

Run in terminalbash
docker --version
docker compose version

If Docker is missing, use our Docker install guide or the official docs.

Checklist:

  • [ ] Docker and Compose answer both commands
  • [ ] Port 9001 on this machine is free
  • [ ] Disk: about 1.5 GB for the images, and the docs suggest 50 GB or more for a real team's database
  • [ ] HTTPS section only: a domain with a DNS record for the server, and ports 80 and 443 free
  • [ ] Mail account or SMTP service for invitations (you can start without one)

Tested by DIY Lab Hub: not yet. The commands come from the official compose file and docs at release 2.18.6, read in November 2026. We did not run them.

Step 1: Create the folder#

Everything for Penpot lives in ~/docker_volumes/penpot: the Compose file, the secrets file and your backups. The data itself lives in two Docker volumes, as the official file does.

We use Docker volumes, not bind-mounted folders: the docs describe volumes, and the user ID the containers write with is not documented.

Linux#

Run in terminalbash
mkdir -p ~/docker_volumes/penpot/backups
cd ~/docker_volumes/penpot

Windows (PowerShell)#

Codepowershell
New-Item -ItemType Directory -Force "$HOME\docker_volumes\penpot\backups"
Set-Location "$HOME\docker_volumes\penpot"

Mac (Terminal)#

Same commands as Linux:

Run in terminalbash
mkdir -p ~/docker_volumes/penpot/backups
cd ~/docker_volumes/penpot

Check it worked. pwd (or Get-Location in PowerShell) shows the new folder.

Step 2: Create the secrets file#

One small .env file holds the settings you change: the version, the address, the flags, a secret key and a database password. The official file ships change-this-insecure-key as the secret and penpot as the database password. We generate our own.

The secret key matters. The docs say that without a fixed key, sessions and invitations stop working after every restart.

The flags below are the official defaults for a first run on this machine. disable-email-verification and disable-secure-session-cookies make a plain-HTTP start possible; the HTTPS section removes them.

Linux#

Run in terminalbash
cat > .env << EOF
PENPOT_VERSION=2.18.6
PENPOT_PUBLIC_URI=http://localhost:9001
PENPOT_FLAGS="disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console"
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
PENPOT_DB_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env

Windows (PowerShell)#

Run in terminalpowershell
function New-Hex($n) { $b = New-Object byte[] $n; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); -join ($b | ForEach-Object { $_.ToString('x2') }) }
$text = @"
PENPOT_VERSION=2.18.6
PENPOT_PUBLIC_URI=http://localhost:9001
PENPOT_FLAGS="disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console"
PENPOT_SECRET_KEY=$(New-Hex 64)
PENPOT_DB_PASSWORD=$(New-Hex 24)
"@
[System.IO.File]::WriteAllText("$HOME\docker_volumes\penpot\.env", ($text -replace "`r`n", "`n"))

Mac (Terminal)#

Same commands as Linux (openssl ships with macOS):

Run in terminalbash
cat > .env << EOF
PENPOT_VERSION=2.18.6
PENPOT_PUBLIC_URI=http://localhost:9001
PENPOT_FLAGS="disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console"
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
PENPOT_DB_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env

Check it worked. The file has five lines and two long random values. Never paste its content anywhere.

Tip: Back up .env with your data. The secret key is needed to keep sessions and invitations valid, and the database password must match the existing database. A new random password with an old volume gives a login failure.

Step 3: Write the Compose file and start Penpot#

This is the official 2.18.6 file with these changes: values come from .env, the tag is pinned, the web port and Mailpit inbox listen on 127.0.0.1 only (the official file publishes them on every interface), telemetry is off, restart: unless-stopped replaces always, and the volumes have fixed names. All services are kept: the frontend waits for them, and a forum thread reports a restart loop after removing the MCP service.

Save the file below as docker-compose.yml in the Penpot folder. Open an empty file with nano docker-compose.yml (Linux, Mac) or notepad docker-compose.yml (Windows), paste, and save.

File contentyaml
name: penpot

x-flags: &penpot-flags
  PENPOT_FLAGS: ${PENPOT_FLAGS}

x-uri: &penpot-public-uri
  PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}

x-body-size: &penpot-http-body-size
  PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
  PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600

x-secret-key: &penpot-secret-key
  PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}

x-database: &penpot-database
  PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
  PENPOT_DATABASE_USERNAME: penpot
  PENPOT_DATABASE_PASSWORD: ${PENPOT_DB_PASSWORD}

networks:
  penpot:

volumes:
  penpot_postgres_v15:
    name: penpot_postgres_v15
  penpot_assets:
    name: penpot_assets

services:
  penpot-frontend:
    image: "penpotapp/frontend:${PENPOT_VERSION:-2.18.6}"
    restart: unless-stopped
    ports:
      - "127.0.0.1:9001:8080"
    volumes:
      - penpot_assets:/opt/data/assets
    depends_on:
      - penpot-backend
      - penpot-admin-console
      - penpot-mcp
      - penpot-exporter
    networks:
      - penpot
    environment:
      << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
      PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000

  penpot-backend:
    image: "penpotapp/backend:${PENPOT_VERSION:-2.18.6}"
    restart: unless-stopped
    volumes:
      - penpot_assets:/opt/data/assets
    depends_on:
      penpot-postgres:
        condition: service_healthy
      penpot-valkey:
        condition: service_healthy
    networks:
      - penpot
    environment:
      << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database]
      PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
      PENPOT_REDIS_URI: redis://penpot-valkey/0
      PENPOT_OBJECTS_STORAGE_BACKEND: fs
      PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
      PENPOT_TELEMETRY_ENABLED: "false"
      PENPOT_SMTP_DEFAULT_FROM: ${PENPOT_SMTP_DEFAULT_FROM:[email protected]}
      PENPOT_SMTP_DEFAULT_REPLY_TO: ${PENPOT_SMTP_DEFAULT_FROM:[email protected]}
      PENPOT_SMTP_HOST: ${PENPOT_SMTP_HOST:-penpot-mailpit}
      PENPOT_SMTP_PORT: ${PENPOT_SMTP_PORT:-1025}
      PENPOT_SMTP_USERNAME: ${PENPOT_SMTP_USERNAME:-}
      PENPOT_SMTP_PASSWORD: ${PENPOT_SMTP_PASSWORD:-}
      PENPOT_SMTP_TLS: ${PENPOT_SMTP_TLS:-false}
      PENPOT_SMTP_SSL: "false"

  penpot-admin-console:
    image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18.6}"
    restart: unless-stopped
    depends_on:
      penpot-postgres:
        condition: service_healthy
    networks:
      - penpot
    environment:
      << : [*penpot-public-uri, *penpot-secret-key, *penpot-database]
      PENPOT_INTERNAL_URI: http://penpot-frontend:8080

  penpot-mcp:
    image: "penpotapp/mcp:${PENPOT_VERSION:-2.18.6}"
    restart: unless-stopped
    networks:
      - penpot

  penpot-exporter:
    image: "penpotapp/exporter:${PENPOT_VERSION:-2.18.6}"
    restart: unless-stopped
    depends_on:
      penpot-valkey:
        condition: service_healthy
    networks:
      - penpot
    environment:
      << : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
      PENPOT_INTERNAL_URI: http://penpot-frontend:8080
      PENPOT_REDIS_URI: redis://penpot-valkey/0

  penpot-postgres:
    image: "postgres:15"
    restart: unless-stopped
    stop_signal: SIGINT
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U penpot"]
      interval: 2s
      timeout: 10s
      retries: 5
      start_period: 2s
    volumes:
      - penpot_postgres_v15:/var/lib/postgresql/data
    networks:
      - penpot
    environment:
      - POSTGRES_INITDB_ARGS=--data-checksums
      - POSTGRES_DB=penpot
      - POSTGRES_USER=penpot
      - POSTGRES_PASSWORD=${PENPOT_DB_PASSWORD}

  penpot-valkey:
    image: valkey/valkey:8.1
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
      interval: 1s
      timeout: 3s
      retries: 5
      start_period: 3s
    networks:
      - penpot
    environment:
      - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu

  penpot-mailpit:
    image: axllent/mailpit:v1
    restart: unless-stopped
    expose:
      - "1025"
    ports:
      - "127.0.0.1:8025:8025"
    networks:
      - penpot

Then check the file and start Penpot. The first start downloads about 1.5 GB.

Linux#

Run in terminalbash
docker compose config --quiet
docker compose up -d
docker compose ps
curl -I http://localhost:9001

Windows (PowerShell)#

Run in terminalpowershell
docker compose config --quiet
docker compose up -d
docker compose ps
curl.exe -I http://localhost:9001

Mac (Terminal)#

Same commands as Linux.

Check it worked. docker compose config --quiet prints nothing when the file is valid. docker compose ps should list eight services; Postgres and Valkey show healthy. Per the docs, Penpot now listens on http://localhost:9001, and a status line from curl means the web container answers. The docs publish no log text to compare, so read docker compose logs --tail=50 penpot-backend for errors.

Tip: After you edit .env or the Compose file, run docker compose up -d again. A plain restart keeps the old settings, and a changed address or flag then seems to be ignored.

Step 4: Create your account, then close sign-up#

Open http://localhost:9001 in your browser and create the first account. Registration with email and password is on by default.

When your team's accounts exist, close sign-up so strangers cannot create accounts. The docs call disable-registration "only recommended for demo instances", but for a private team server it is the simple switch, with one catch (next step).

Add disable-registration to the end of the PENPOT_FLAGS line in .env, then recreate the containers.

Linux#

Run in terminalbash
sed -i 's/^PENPOT_FLAGS="\(.*\)"$/PENPOT_FLAGS="\1 disable-registration"/' .env
docker compose up -d
grep PENPOT_FLAGS .env

Windows (PowerShell)#

Codepowershell
(Get-Content .env) -replace '^PENPOT_FLAGS="(.*)"$', 'PENPOT_FLAGS="$1 disable-registration"' | Set-Content .env
docker compose up -d
Select-String PENPOT_FLAGS .env

If your PowerShell is version 5.1, Set-Content may add a byte order mark. Edit .env in Notepad instead and add disable-registration before the closing quote.

Mac (Terminal)#

macOS sed needs an empty backup suffix:

Run in terminalbash
sed -i '' 's/^PENPOT_FLAGS="\(.*\)"$/PENPOT_FLAGS="\1 disable-registration"/' .env
docker compose up -d
grep PENPOT_FLAGS .env

Check it worked. The flags line ends with disable-registration, and the sign-up page no longer lets a new visitor register.

New accounts are now made by you, on the server. The docs describe a script for it; it works because the enable-prepl-server flag is set:

Run in terminalbash
docker compose exec penpot-backend python3 manage.py create-profile

The docs show this command without options and say the container name depends on your system. A report in the Penpot tracker (issue 11376) uses -n "<full name>" -e <email>; run the plain command first and answer what it asks.

Step 5: Make invitations work (email)#

A team needs email: the docs say people accept an invitation from an email before they join. Our Compose file sends mail to Mailpit, the test mail server from the official file. Open http://127.0.0.1:8025 on the server to read what Penpot "sent". The messages stay in memory only, and nothing reaches a real inbox.

When you want real invitations, put your mail provider's data in .env. Open it with nano .env (Linux, Mac) or notepad .env (Windows). The docs show port 587 with TLS on:

Run in terminaltext
PENPOT_SMTP_HOST=smtp.example.com
PENPOT_SMTP_PORT=587
PENPOT_SMTP_USERNAME=<your-smtp-user>
PENPOT_SMTP_PASSWORD=<your-smtp-password>
PENPOT_SMTP_TLS=true
PENPOT_SMTP_DEFAULT_FROM="Penpot <[email protected]>"

Then recreate the containers and invite a test address:

Linux#

Run in terminalbash
cd ~/docker_volumes/penpot
docker compose up -d

Windows (PowerShell)#

Codepowershell
Set-Location "$HOME\docker_volumes\penpot"
docker compose up -d

Mac (Terminal)#

Same commands as Linux.

Check that the mail arrives. Other ports and SSL settings are not described in the docs, so test them yourself. For your own mail server, see our Stalwart Mail guide. A $ in a password must be written $$ in .env.

Three behaviours to know:

  • With disable-email-verification, inviting someone who already has an account adds them at once, with no email (issue 11376; the docs were corrected after 2.18.6).
  • With disable-registration, a new person cannot sign up through an invitation link. Create the account first with the script above (a forum thread still asks about this in 2026).
  • No mail server? The docs list enable-log-invitation-tokens and enable-log-emails, which print links to the log. To limit sign-up to one company domain instead, see PENPOT_REGISTRATION_DOMAIN_WHITELIST.

Check it worked. Invite a second address from the team page. With Mailpit the message appears at http://127.0.0.1:8025. With a real server, it appears in the inbox.

Put it behind HTTPS (with or without Traefik)#

The docs "strongly recommend" HTTPS, and copy and paste fails without it (issue 8281). Anyone on another computer needs it. If you only use Penpot on the machine it runs on, skip this section. Both routes need a name for the server, such as penpot.example.com (public) or penpot.home.arpa (home only).

First change .env: set the real address, and remove disable-email-verification and disable-secure-session-cookies, as the official file says to when Penpot faces the internet:

Run in terminaltext
PENPOT_PUBLIC_URI=https://penpot.example.com
PENPOT_FLAGS="enable-smtp enable-prepl-server enable-mcp enable-admin-console disable-registration"

Without disable-email-verification, Penpot sends a verification mail, so configure real SMTP first (Step 5). Both routes use an external Docker network called proxy (the same command works in PowerShell and Terminal):

Run in terminalbash
docker network create proxy

docker compose reads a file named docker-compose.override.yml automatically.

With Traefik#

On a server that already runs Traefik v3 with the Docker provider, add labels to the web container. This follows the commented Traefik example in the official file and names the port. Use your own resolver name instead of letsencrypt and your entry point name instead of https (the official example says websecure).

Save as docker-compose.override.yml:

File contentyaml
services:
  penpot-frontend:
    networks:
      - penpot
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.penpot.rule=Host(`penpot.example.com`)"
      - "traefik.http.routers.penpot.entrypoints=https"
      - "traefik.http.routers.penpot.tls=true"
      - "traefik.http.routers.penpot.tls.certresolver=letsencrypt"
      - "traefik.http.services.penpot.loadbalancer.server.port=8080"

networks:
  proxy:
    external: true

Start it with docker compose up -d.

Check it worked. curl -I https://penpot.example.com/ returns an HTTP status line over a valid certificate, and the login page opens.

Without Traefik: Caddy#

Choose this if you have no Traefik. Caddy gets and renews its own certificates, and its proxy supports WebSockets. The Penpot docs show reverse_proxy :9001. We run Caddy in Docker on the shared network, so it reaches the web container by name. More in our Caddy guide.

Create the Caddy folder:

Run in terminalbash
mkdir -p ~/docker_volumes/caddy/{conf,data,config}
cd ~/docker_volumes/caddy

Save as ~/docker_volumes/caddy/docker-compose.yml. The image caddy:2.11.8 exists on Docker Hub (release 2.11.9 had no image yet). We mount the conf folder, not one file, as the Caddy image docs advise:

File contentyaml
services:
  caddy:
    image: caddy:2.11.8
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - ./data:/data
      - ./config:/config
    networks:
      - proxy

networks:
  proxy:
    external: true

Save the site as ~/docker_volumes/caddy/conf/Caddyfile. For a public domain:

File contenttext
penpot.example.com {
    reverse_proxy penpot-frontend:8080
}

For a home-only name, Caddy signs a local certificate:

File contenttext
penpot.home.arpa {
    tls internal
    reverse_proxy penpot-frontend:8080
}

Now attach Penpot's web container to the same network. Save this as docker-compose.override.yml in the Penpot folder:

File contentyaml
services:
  penpot-frontend:
    networks:
      - penpot
      - proxy

networks:
  proxy:
    external: true

Start both stacks:

Run in terminalbash
cd ~/docker_volumes/caddy && docker compose up -d
cd ~/docker_volumes/penpot && docker compose up -d

On Windows, run the same Compose commands folder by folder in PowerShell. A home-only certificate needs Caddy's root trusted on each device; steps for Linux, Windows and Mac are in our Caddy guide.

Check it worked. curl -I https://penpot.example.com/ returns a status line over HTTPS. For a home-only name, add -k until the root is trusted.

Tip: Let's Encrypt needs the DNS record to point at the server and ports 80 and 443 to reach it. For testing, Caddy's docs suggest the staging server, because the real one limits failed tries.

Security and testing#

Penpot is a web application with accounts and file uploads. Treat it like any internet-facing service.

ControlHow
Run the latest patch2.18.6 fixes an upload bug (issue 12771): a request could make the server store one of its own files as an asset. Do not stay on 2.18.5 or older with open sign-up
Secrets.env is chmod 600 and never in a shared folder. A changed secret key ends all sessions
Sign-updisable-registration once your team has accounts, or limit it by email domain
FlagsOn the internet, remove disable-email-verification and disable-secure-session-cookies (the official file says so)
PortsPenpot (9001) and Mailpit (8025) listen on 127.0.0.1. Only the proxy opens 80 and 443
LoginEmail and password, or OpenID Connect, Google, GitHub, GitLab or LDAP (open docs). See our Authentik and Pocket ID guides
MCPIt is reachable at the same address and lets a connected AI client edit the open design. Connect only clients you trust

What leaves the machine, per the docs: telemetry (we set it off; the official file asks you to leave it on to help the project), plus nginx proxies to GitHub (libraries, templates) and Google (fonts). enable-air-gapped-conf removes both, and with them the templates dashboard and Google fonts.

Quick tests:

Run in terminalbash
cd ~/docker_volumes/penpot
docker compose ps
docker compose logs --tail=100 penpot-backend
curl -I https://penpot.example.com/

Penpot and Figma: the honest gaps#

We did not compare feature by feature, so test your must-haves in the free cloud service first. These points come from Penpot's docs and tracker:

  • Browser only. A desktop app is a feature request (issue 6199, open). The docs recommend a powerful computer and current Chrome.
  • Self-hosted lags the cloud. Images are published shortly after the cloud update.
  • The new renderer is a beta. WebGL (since 2.16) is off by default; view mode and exports still use the old renderer.
  • Moving from Figma is a one-off. The docs describe an exporter plugin; Auto Layout becomes Flex and Grid, plugins do not come along, and there is no continuous sync.
  • Governance is paid. Organizations, the Admin Console and SSO modules are Enterprise. OIDC and LDAP login are in the open docs.
  • Own plugin system. Do not expect a Figma plugin to run.

Common mistakes#

These come from the Penpot docs, GitHub issues, forum threads and common AI assistant answers.

MistakeWhy it happensDo this instead
Changing PENPOT_PUBLIC_URI but the app still calls localhost:9001A restart keeps old settings, and the address must match the browser's. One forum thread ended with a firewall rule rejecting application/transit+json; issue 10167 closed with "ensure PENPOT_PUBLIC_URI is correctly set"Edit .env, run docker compose up -d, and check the proxy or firewall for blocked API calls
Plain HTTP on another computerLogin fails with "Something wrong has happened" without disable-secure-session-cookies (issue 6788); with it, copy and paste crashes (issue 8281)Use the HTTPS section; keep the flag for the same machine only
Demo flags and default secrets on a public serverThe official file ships change-this-insecure-key, database password penpot, and warns about two flagsRemove both flags, set up SMTP, use the generated values from Step 2
Removing the penpot-mcp serviceAfter 2.15 the frontend restarts when the MCP host is missing (forum, May 2026)Keep the service, or follow that thread's disable-mcp settings
Updating across many versions at onceThe docs advise small steps. In issue 8788 a 2.13.3 to 2.14.0 update left a file with custom fonts unopenable until the reporter rolled backBack up, update one minor version at a time, open a test file
Expecting real invitations from the default mail setupThe example sends all mail to Mailpit, which keeps it in memorySet the PENPOT_SMTP_* values (Step 5) and test with your own address
Sharing a PostgreSQL with other appsSince 2.16 the backend opens 60 connections at start and keeps them (issue 12622, open)Use the dedicated Postgres from this guide
AI assistants: pasting an old compose fileMany examples use Redis, latest, or PENPOT_STORAGE_ASSETS_*. Valkey replaced Redis, and the storage variables became PENPOT_OBJECTS_STORAGE_* in 2.11Start from the official file at your tag, or this guide, and compare variable names with the configuration page
AI assistants: docker-compose v1 commands or a version: lineOld snippets use them; current Compose warns that version is obsoleteUse docker compose and no version: line

Tip: Run docker compose config after every edit. It prints the final values, so you see the address and flags Penpot will really get.

Tip: Back up before every update. In issue 8788 the file opened again only after rolling back, so keep a backup from before the update.

Tip: Test invitations with an address you control before you tell the team. A silent mail problem looks like "nobody joins".

Troubleshooting#

ProblemSolution
Black page or "Something wrong has happened."Open the browser console (F12). PENPOT_PUBLIC_URI must match the address in the bar. For HTTP on another host the cookie flag is needed (issue 6788). Run docker compose up -d after edits
Stylesheet blocked, MIME type text/htmlReported in issue 10049 (open, cause not confirmed). Check that your proxy sends everything to Penpot, clear the cache, read docker compose logs penpot-frontend
penpot-frontend keeps restartingdocker compose logs penpot-frontend. If the MCP host is missing, keep penpot-mcp in the file
Invitation mail does not arriveLook in Mailpit at http://127.0.0.1:8025. For a real server, read docker compose logs penpot-backend and check port, TLS and password
Export fails or times outdocker compose logs penpot-exporter; see the known bugs below
Postgres is not healthydocker compose logs penpot-postgres. A changed PENPOT_DB_PASSWORD with an old volume fails
A file will not open after an updateRoll back to the earlier tag, restore your backup and report it (issue 8788)

Known bugs (open, November 2026)#

  • Issue 11661: exports can time out because the exporter's cookie gets the wrong domain when PENPOT_INTERNAL_URI has a port, as in the official file.
  • Issue 10339: zombie chrome-headless processes after exporting elements.
  • Issue 12003: the built-in MCP plugin on a multi-user self-hosted instance can be refused for a missing user token.
  • Issue 12622: 60 idle database connections.

Diagnostics: docker compose ps, docker compose logs --tail=200 <service>, the browser console, and the crash report Penpot shows.

Updates and backups#

Update. Read the release notes first. The docs say to update in small steps and that images follow the cloud release. Back up, change PENPOT_VERSION in .env, then:

Run in terminalbash
cd ~/docker_volumes/penpot
docker compose pull
docker compose up -d
docker compose logs --tail=100 penpot-backend

Version 2.18 added the penpot-admin-console service, which our file has. Older files need the changes from the docs ("Upgrade to 2.18").

Back up. Two volumes hold your data: Postgres and assets. The docs say you cannot just copy a volume folder, so we dump the database with PostgreSQL's own pg_dump (standard PostgreSQL, not a Penpot command) and archive the assets with a temporary container. You can also export single files as .penpot from the dashboard.

Linux#

Save as ~/docker_volumes/penpot/backup.sh:

File contentbash
#!/usr/bin/env bash
set -euo pipefail
cd ~/docker_volumes/penpot
STAMP=$(date +%F)
mkdir -p backups
docker compose exec -T penpot-postgres pg_dump -U penpot -Fc -f /tmp/penpot.dump penpot
docker compose cp penpot-postgres:/tmp/penpot.dump "backups/penpot-db-$STAMP.dump"
docker compose exec -T penpot-postgres rm /tmp/penpot.dump
docker run --rm -v penpot_assets:/data:ro -v "$PWD/backups:/backup" postgres:15 tar czf "/backup/penpot-assets-$STAMP.tar.gz" -C /data .
cp .env "backups/env-$STAMP"
chmod 600 backups/*
find backups -type f -mtime +14 -delete

Make it runnable and add a nightly 03:00 entry:

Run in terminalbash
chmod +x ~/docker_volumes/penpot/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * $HOME/docker_volumes/penpot/backup.sh") | crontab -

Windows (PowerShell)#

Save as $HOME\docker_volumes\penpot\backup.ps1:

Codepowershell
Set-Location "$HOME\docker_volumes\penpot"
$stamp = Get-Date -Format yyyy-MM-dd
New-Item -ItemType Directory -Force backups | Out-Null
docker compose exec -T penpot-postgres pg_dump -U penpot -Fc -f /tmp/penpot.dump penpot
docker compose cp penpot-postgres:/tmp/penpot.dump "backups\penpot-db-$stamp.dump"
docker compose exec -T penpot-postgres rm /tmp/penpot.dump
docker run --rm -v penpot_assets:/data:ro -v "${PWD}\backups:/backup" postgres:15 tar czf "/backup/penpot-assets-$stamp.tar.gz" -C /data .
Copy-Item .env "backups\env-$stamp"
Get-ChildItem backups -File | Where-Object LastWriteTime -lt (Get-Date).AddDays(-14) | Remove-Item

Schedule it with Task Scheduler:

Codepowershell
$a = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -File `"$HOME\docker_volumes\penpot\backup.ps1`""
$t = New-ScheduledTaskTrigger -Daily -At 3:00
Register-ScheduledTask -TaskName "Penpot backup" -Action $a -Trigger $t

Mac (Terminal)#

Use the Linux script. Schedule it with the same crontab line; Docker Desktop must be running at 03:00. Keep a copy of backups/ on another machine, for example with restic.

Restore test. Check that a dump can be read, using a throwaway container (replace the date):

Run in terminalbash
cd ~/docker_volumes/penpot
docker run --rm -v "$PWD/backups:/b:ro" postgres:15 pg_restore -l /b/penpot-db-<date>.dump

On Windows, use ${PWD}\backups for the folder. A list of tables means the dump is readable. For a full restore test, load the dump with pg_restore into an empty database, unpack the assets into an empty volume and start a second Penpot with the same .env. Do it once before you rely on the backups.

Performance and resources#

The project publishes no minimum for a small team. Its guidance is for large installs: 4 CPUs and 16 GB RAM for thousands of users, and 50 to 100 GB of database space for up to 10 editors, plus about 5 GB per extra editor. We did not measure memory, so measure yours:

Run in terminalbash
docker stats --no-stream
  • Valkey is capped at 128 MB in the official file and uses RAM only.
  • Exports run in a headless browser in the exporter container and are the usual heavy part. The docs list PENPOT_EXPORTER_MAX_CONCURRENT_JOBS and related limits (commented out in the official file).
  • Images are processed with ImageMagick limits (2 threads, 256 MiB, 30 seconds per operation by default).
  • Autosave history is off for self-hosted installs; the docs warn that switching it on can grow the database a lot.
  • Clients: the canvas runs in the browser, so designers' computers matter.

FAQ#

Is Penpot free to self-host? Yes, under MPL-2.0. The paid Enterprise plan adds organizations, an Admin Console and SSO modules.

Can I move from Figma? The docs describe a Penpot Exporter plugin that runs inside Figma, for a one-off move with cleanup afterwards. We did not try it.

Does it need a GPU? The docs name none for the server. The browser may use WebGL if you switch on the beta renderer.

Can I run it on Windows or a Mac? The images exist for amd64 and arm64, so Docker Desktop should work. The docs give no support list and we did not test it. Use Linux for a team.

Does Penpot send my data anywhere? The official file turns telemetry on; we turn it off. Libraries, templates and Google fonts load through proxies unless you set enable-air-gapped-conf.

Can AI tools use my designs? The MCP server lets an MCP client read and change the open file. Only connect clients you trust, because they can edit.

Sources#

Checked against the official docs on 2026-11-05.