What you will set up
Penpot is a design and prototyping tool that runs in the browser, much like Figma. Designers draw screens with components, shared libraries, design tokens and flex and grid layouts, link them into clickable prototypes, and comment on them, several people at once in the same file. Developers open the same file to read sizes, colours and the generated CSS and HTML, and export images. Its flex and grid layouts work like CSS flexbox and grid, so what designers draw is close to what developers build.
Penpot is made by Kaleidos, a company whose team works from Madrid, and is open source under the Mozilla Public License 2.0. The version you run yourself has every design feature and no limit on users, teams or files. On 14 October 2026, Penpot's own cloud was free with up to 8 members per team, 10 GB of storage and 7 days of version history; Unlimited, at $7 per editor and month and never more than $175 a month, adds unlimited team size, 25 GB and 30 days; Enterprise, at $25 per member and month, adds unlimited storage, 90 days, single sign-on, a choice of hosting region and IP allow lists. Penpot also sells a dedicated server for $50,000 a year. Self-hosted, storage and history are limited only by your server, and logins through your own single sign-on (OpenID Connect or LDAP) are built in.
Here Penpot runs from its official images as a pod under a user of its own called penpot: the frontend, the backend, the exporter that renders PNG, SVG and PDF files, PostgreSQL for the data and Valkey for the live updates between users. Caddy from the Podman guide sits in front.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Penpot 2.18.3 started from scratch, and the first account was created over an SSH tunnel before Caddy was set up. With sign-ups closed, the sign-up form answered "The registration is currently disabled."
- A second account was made from the command line, invited to a team by email, and joined it with the link. Invitations, password resets and comment notifications reached a test mail catcher on the server.
- A shared library got two components with flex layout, a colour and design tokens. Another file used the components, and when a token changed in the library, the file showed "There are updates in shared libraries" and took the new colour.
- A prototype went from one board to another on a click in the viewer. Two people in the same file saw each other's cursor and edits live, and a comment thread got a reply.
- The inspect tab showed the layout as CSS and the code as HTML. A board was exported as PNG, SVG and PDF through the exporter, and a file went out and back in as a
.penpotfile. A.figfile was refused. - A plugin was installed from its address and built a library and a page in the open file.
- With telemetry off, a capture of the server's traffic showed no connections to Penpot, only the Google Fonts lookups described in step 14.
- An update from 2.17.2 to 2.18.3 ran its database migrations by itself. The whole instance was deleted and restored from a backup with a new user, and everything came back by itself after a reboot.
The pod used about 1.45 GB of memory at rest, 1.2 GB of it the backend's Java process, and about 1.75 GB with three people editing and an export running. The images take 4.2 GB of disk. That leaves plenty of room on the Alloy plan.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running;
- an A record and an AAAA record for
design.example.compointing at your server; - for invitations, an email account at your email provider that may send through SMTP.
The examples use design.example.com for Penpot, 203.0.113.10 for your server's IPv4 address and example.com for your email addresses. Replace them throughout.
1. Create the user
As root:
useradd -m -s /bin/bash penpot
loginctl enable-linger penpot
machinectl shell penpot@
Everything up to step 6 runs as penpot.
2. Create the secrets
openssl rand -base64 64 | tr -d '\n' | podman secret create penpot-secret-key -
openssl rand -hex 24 | tr -d '\n' | podman secret create penpot-db-password -
The first is Penpot's secret key, 512 random bits from which it derives the keys for logins and invitation links. If it changes, everyone is logged out and sent invitations stop working, so step 15 saves a copy. The second is the database password. Both are stored by Podman, not in the files below.
3. Define the pod
mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd
Create penpot.pod:
[Pod]
PodName=penpot
# Only Caddy, on this server, can reach Penpot: the port is not open to the internet.
PublishPort=127.0.0.1:8120:8080
[Install]
WantedBy=default.target
Create penpot-db.container:
[Container]
ContainerName=penpot-db
Image=docker.io/library/postgres:17
Pod=penpot.pod
Volume=penpot-db:/var/lib/postgresql/data
Environment=POSTGRES_DB=penpot POSTGRES_USER=penpot POSTGRES_INITDB_ARGS=--data-checksums
Secret=penpot-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U penpot -d penpot
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
Create penpot-valkey.container:
[Container]
ContainerName=penpot-valkey
Image=docker.io/valkey/valkey:8.1
Pod=penpot.pod
Exec=valkey-server --maxmemory 128mb --maxmemory-policy volatile-lfu --save ""
HealthCmd=valkey-cli ping
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
Create penpot-backend.container:
[Unit]
Requires=penpot-db.service penpot-valkey.service
After=penpot-db.service penpot-valkey.service
[Container]
ContainerName=penpot-backend
Image=docker.io/penpotapp/backend:2.18
Pod=penpot.pod
Volume=penpot-assets:/opt/data/assets
EnvironmentFile=%h/penpot.env
Environment=PENPOT_DATABASE_URI=postgresql://127.0.0.1/penpot PENPOT_DATABASE_USERNAME=penpot
Environment=PENPOT_REDIS_URI=redis://127.0.0.1/0
Environment=PENPOT_OBJECTS_STORAGE_BACKEND=fs PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets
Environment=PENPOT_TELEMETRY_ENABLED=false
Secret=penpot-secret-key,type=env,target=PENPOT_SECRET_KEY
Secret=penpot-db-password,type=env,target=PENPOT_DATABASE_PASSWORD
AutoUpdate=registry
[Service]
Restart=always
Create penpot-exporter.container:
[Unit]
Requires=penpot-valkey.service
After=penpot-valkey.service
[Container]
ContainerName=penpot-exporter
Image=docker.io/penpotapp/exporter:2.18
Pod=penpot.pod
EnvironmentFile=%h/penpot.env
Environment=PENPOT_INTERNAL_URI=http://127.0.0.1:8080 PENPOT_REDIS_URI=redis://127.0.0.1/0
Secret=penpot-secret-key,type=env,target=PENPOT_SECRET_KEY
AutoUpdate=registry
[Service]
Restart=always
And penpot-frontend.container:
[Unit]
Requires=penpot-backend.service
After=penpot-backend.service
[Container]
ContainerName=penpot-frontend
Image=docker.io/penpotapp/frontend:2.18
Pod=penpot.pod
Volume=penpot-assets:/opt/data/assets
EnvironmentFile=%h/penpot.env
Environment=PENPOT_BACKEND_URI=http://127.0.0.1:6060 PENPOT_EXPORTER_URI=http://127.0.0.1:6061
AutoUpdate=registry
[Service]
Restart=always
The settings all three Penpot containers share go in one file, ~/penpot.env. For now it is set up for the first account, which you create in step 4 before anyone else can reach Penpot:
PENPOT_PUBLIC_URI=http://localhost:8120
PENPOT_FLAGS=enable-registration disable-email-verification enable-login-with-password disable-onboarding disable-telemetry
What the settings do:
- The containers share the pod's network, so they find each other on
127.0.0.1: the backend on port 6060, the exporter on 6061 and the frontend on 8080. The frontend serves the app and passes/apito the backend and exports to the exporter. - The exporter is a headless Chromium that opens the frontend at
PENPOT_INTERNAL_URIand renders boards to PNG, SVG and PDF. It is the largest image, about 2 GB. - Uploaded images, fonts and thumbnails go in the
penpot-assetsvolume, which the backend writes and the frontend serves. The designs themselves are in PostgreSQL. PENPOT_TELEMETRY_ENABLED=falsekeeps telemetry off. Penpot's own Docker Compose file turns it on; step 14 has the details.- Valkey only passes live updates between users, so it keeps nothing on disk.
- The
2.18tags follow the bug-fix releases of Penpot 2.18, whichpodman auto-updateinstalls. Step 16 covers moving to the next version. PostgreSQL stays on 17; Penpot supports 13 and newer, and its own Compose file uses 15.
Penpot's Compose file for 2.18 also starts an admin console and an MCP server, which lets AI assistants edit designs. Both are optional and need flags of their own; this guide leaves them out.
Start it:
systemctl --user daemon-reload
systemctl --user start penpot-pod
systemctl --user enable --now podman-auto-update.timer
The first start downloads about 4 GB of images, and the backend takes half a minute to set up its database. It is ready when this prints OK:
curl -s http://127.0.0.1:8120/readyz; echo
4. Create the first account
Penpot has no default account: the first person to sign up gets in. So create your account now, while Penpot can only be reached from the server. On your own computer, open a tunnel:
ssh -L 8120:127.0.0.1:8120 root@203.0.113.10
Open http://localhost:8120/#/auth/register in your browser. Fill in Full name, Work email and Password (at least 8 characters, one of them a special character), leave Send me product updates unticked, and choose Create an account. Penpot opens the dashboard with your account and a team of your own called Personal Projects. Close the tunnel when you are done.
Leave the box unticked for a reason: if anyone on the server has ticked it, Penpot sends their email address to telemetry.penpot.app every few hours, even with telemetry off. The box is only on the sign-up form, which is closed after the next step.
5. Close sign-ups
Now give ~/penpot.env its real address and its lasting flags:
PENPOT_PUBLIC_URI=https://design.example.com
PENPOT_FLAGS=disable-registration enable-login-with-password enable-email-verification disable-demo-users disable-onboarding disable-telemetry enable-prepl-server
systemctl --user restart penpot-pod
PENPOT_PUBLIC_URI is the address Penpot uses in its links and emails, and the one the browser app sends its requests to. It was http://localhost:8120 for the tunnel. Each flag turns a feature on or off:
disable-registrationremoves sign-up. The form at/#/auth/registeranswers "The registration is currently disabled." and the login page has no link to it. New people get an account from you (step 8).enable-login-with-passwordlets people log in with their email address and password. It is the default, and stays written out so that the line says how people log in. If you use single sign-on,disable-login-with-passwordturns the form off.enable-email-verificationmakes people confirm a new email address from a link before it is used. It is also the default.disable-demo-userskeeps the Create demo account link away, which makes throwaway accounts that delete themselves after 7 days. It is off by default; the flag makes sure.disable-onboardingskips the questions every new user is asked first, about what they will use Penpot for and which tool they come from.disable-telemetryis the flag form ofPENPOT_TELEMETRY_ENABLED=false.enable-prepl-serverlets the command-line tool in the backend create accounts (step 8). It listens on port 6063 inside the pod only.
Logins stay on secure cookies, which is the default. Penpot's Compose file turns them off with disable-secure-session-cookies so that it works over plain HTTP; behind Caddy you do not need that. The flags only take effect when the containers start, which is why every change ends with a restart.
6. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
design.example.com {
reverse_proxy 127.0.0.1:8120
}
Restart Caddy with systemctl --user restart caddy, open https://design.example.com and log in with the account from step 4. Penpot keeps live connections open to every user for real-time editing; Caddy passes them on without any extra settings.
7. Email
Penpot sends email for team invitations, password resets, confirmation of a changed email address, and notifications about comments. Without email:
- an invitation shows "Invitation sent successfully", but nobody receives it, and nothing is written to the log. The Copy link button next to the invitation on the Invitations tab gives you the link to send another way;
- Forgot password? says "Password recovery link sent to your inbox." and nothing arrives;
- nobody hears about comments and replies.
Logins and everything else work.
Send through your email provider. As penpot, store the SMTP password as a secret:
printf %s 'the-smtp-password' | podman secret create penpot-smtp-password -
Add these lines to penpot-backend.container, above AutoUpdate=registry, with your own server, account and sender address:
Environment=PENPOT_SMTP_HOST=smtp.example.com PENPOT_SMTP_PORT=587 PENPOT_SMTP_TLS=true
Environment=PENPOT_SMTP_USERNAME=design@example.com
Environment="PENPOT_SMTP_DEFAULT_FROM=Penpot <design@example.com>" PENPOT_SMTP_DEFAULT_REPLY_TO=design@example.com
Secret=penpot-smtp-password,type=env,target=PENPOT_SMTP_PASSWORD
Add enable-smtp at the end of the PENPOT_FLAGS line in ~/penpot.env, and restart:
systemctl --user daemon-reload
systemctl --user restart penpot-pod
Port 587 with PENPOT_SMTP_TLS=true uses STARTTLS. For port 465, which uses TLS from the start, set PENPOT_SMTP_PORT=465, PENPOT_SMTP_TLS=false and PENPOT_SMTP_SSL=true. We tested invitations, password resets and comment notifications against a test mail catcher on the server, with a username and password; sending through a real provider was not tested.
8. Teams and new members
Everything in Penpot belongs to a team: projects, files, shared libraries and fonts. Every user also has a team of their own, Personal Projects. Create the team you will work in from the team menu at the top left, with Create new team.
With sign-ups closed, a new person needs an account before you invite them. As penpot:
podman exec -it penpot-backend python3 manage.py create-profile
It asks for Email, Fullname and Password, in that order, and prints Created: with the address. It checks neither that the email looks like one nor the password rules of the sign-up form, so type carefully and choose a strong password. Send the person the password another way; they can change it under Your account > Password.
Then invite them: open the ⋮ next to the team's name at the top left, choose Invitations, then Invite people, type the address and press Enter, pick a role and choose Send invitation:
- Viewer can open files and comment, but not change anything;
- Editor can create and change projects and files;
- Admin can also invite and remove members.
The person opens the link in the email, logs in, and sees "Joined the team successfully". The order matters: when we sent the invitation before the account existed, logging in with the link opened the person's own Personal Projects instead of the team. Sending the invitation again after create-profile fixed it.
Penpot can also leave logins to a single sign-on service, such as authentik or Keycloak, over OpenID Connect, or to LDAP, with enable-login-with-oidc or enable-login-with-ldap and the matching settings in Penpot's configuration guide. We did not test that here. If the sign-in service runs on this same server, Penpot reaches it only with one of the fixes in step 8 of the Podman guide.
9. A shared library, components and tokens
A small set-up that works for a team: one file holds the design system, and every other file uses it.
- In a project, choose + New file. The file opens; double-click its name at the top left and call it
Brand library. - Draw a button: a board (B) with a text (T) inside it. Select the board and press Shift+A to give it flex layout, so it grows with its label; Ctrl+Shift+A gives it grid layout instead. Padding and gaps are under Layout in the Design tab.
- With the board selected, press Ctrl+K (Cmd+K on a Mac) to make it a component. It now has a diamond icon in the layers list.
- Open the Tokens tab and choose + next to Color. Name the token
color.brandwith a value such as#1F7A4D. Select the button and click the token to apply it to the fill. Do the same for spacing and border radius if you like. - Open the main menu (⋮ next to the file name), then File > Add as Shared Library, and confirm.
In any other file of the team, open the Assets tab, choose Add library, and add Brand library with the + next to it. Its components then appear under Assets, ready to drag onto a board.
When you change something in the library, such as a token's value under Edit token (right-click the token), the files using it show "There are updates in shared libraries" the next time they are opened. Update brings the change in. Keep the file open for a few seconds afterwards: Penpot saves changes in the background, and when we closed the browser right away, the update was lost.
Tokens follow the W3C Design Tokens format. Tools at the bottom of the Tokens tab imports and exports them as JSON, which Penpot's migration guide uses for tokens from Tokens Studio; we did not test that.
10. Prototypes, comments and working together
Prototype. Open the Prototype tab, select an element such as the button, and drag the arrow that appears onto another board. That adds an interaction: on click, navigate to that board. The play button at the top right opens the prototype in a new tab, where clicking the button goes to the other board.
Comments. Press C, or choose the speech bubble at the top right, and click where the comment belongs. Others reply in the same thread, and with email set up, the person mentioned or replied to gets an email. Your account > Notifications sets what each person is told about.
Live editing. Everyone who has the file open sees the others' avatars at the top right, their cursors with their names, and every change as it is made. Penpot also keeps versions: File > Version history, and Pin this version to keep one for good. Autosaved versions are not kept by default on your own server; pinned ones are.
11. Inspect, export and import
Inspect. Developers select an element and open the Inspect tab. It shows sizes, colours, fonts and the flex settings as they would be in CSS, and the menu at the top of the tab switches to Code, with the CSS and HTML for the selection.
Export. Select a board and choose + next to Export at the bottom of the Design tab. Pick PNG, JPG, WEBP, SVG or PDF and a scale, and choose Export 1 element. The exporter renders it and the browser downloads it; a 1280 × 800 board took about five seconds. File > Export boards as PDF puts every board in one PDF.
Files. File > Download Penpot file (.penpot) saves a whole file, with its shared library if you choose Export shared libraries. To bring one in, open the project's menu (⋮ at the top right of the project) and choose Import Penpot files.
From Figma. Penpot cannot open .fig files. The import only accepts .penpot and .zip; when we tried a file with Figma's .fig header, it answered "Something went wrong while processing this file." Penpot's migration guide points to the Penpot Exporter plugin, which runs inside Figma and saves files, components, auto layout and variables in Penpot's format. We could not test that without a Figma account. Static images can always go across as SVG or PNG.
12. Plugins
Plugins add tools to the editor, such as icon sets, charts or content generators. Open the plugin manager with the puzzle icon in the toolbar, or Ctrl+Alt+P, paste the address of a plugin's manifest.json, and choose Install. Penpot lists what the plugin wants to do, such as "Read and modify the content of files that users have access to.", and installs it when you choose Allow. Open then starts it on the open file. Plugins List links to Penpot's catalogue.
A plugin runs in the browser of the person who uses it, and is loaded from the plugin's own address, not from your server. What it can see and do is what it asked for, under that person's account. Install only plugins you trust. We tested with a small plugin of our own, which built a library and a page with the plugin API.
13. Language
Penpot does not come in Swedish. The language list under Your account > Settings > Language has English, Spanish and about 30 community translations, among them Norwegian Bokmål, but not Swedish or Danish, even though translators have started on Swedish. With Auto (browser) and a Swedish browser, Penpot is in English.
14. Outside connections and telemetry
With the settings from steps 3 and 5, we captured the server's DNS lookups and outgoing connections while Penpot started, while people worked, exported, imported files and templates, and after a reboot. Penpot itself contacted nothing outside, with one exception: Google Fonts.
- Google Fonts. The font list includes Google's fonts. When someone uses one, the browser asks your server, and the frontend fetches the font from
fonts.googleapis.comandfonts.gstatic.com. The visitor's browser never contacts Google; your server does. At start, the frontend also looks upraw.githubusercontent.comfor an old templates route, without connecting to it. - Templates. The libraries and templates on the dashboard are built into the backend image; adding one connected to nothing.
- Telemetry. Off here. Penpot's Compose file sets
PENPOT_TELEMETRY_ENABLED: "true". Then the backend sends a report totelemetry.penpot.appat 00:30, 03:30 and every three hours after that, UTC, and at 23:30. In Penpot 2.18.3's code, the report holds an id for your instance, the version, your public address, the number of teams, projects, files, users, fonts and comments, the email domains of all your users, the login methods in use, Java memory figures, and anonymous usage events. To keep it off, keep bothPENPOT_TELEMETRY_ENABLED=falseanddisable-telemetry. - Newsletter. Separate from telemetry, Penpot sends the addresses of users who ticked Send me product updates on the sign-up form to the same address, even with telemetry off. With sign-ups closed, nobody sees the box.
To cut Google Fonts and the templates as well, add enable-air-gapped-conf to PENPOT_FLAGS. Upload your own fonts under Fonts in the dashboard instead. Plugins you install, and links in prototypes, load from their own addresses in the user's browser.
15. Back up
As penpot:
umask 077
mkdir -p ~/backup
podman exec penpot-db pg_dump -U penpot penpot > ~/backup/penpot-db.sql
podman volume export penpot-assets > ~/backup/penpot-assets.tar
podman secret inspect --showsecret --format '{{.SecretData}}' penpot-secret-key > ~/backup/secret-key.txt
The database holds every account, team, file, comment and version; the volume holds uploaded images, fonts and thumbnails; the secret key keeps logins and invitation links valid. The dump runs while Penpot keeps working. Copy ~/backup to another machine, for example with restic, and keep it safe.
The files stay on the server's disk in penpot-assets. To keep them in S3 storage instead, such as Garage, set PENPOT_OBJECTS_STORAGE_BACKEND=s3 with the bucket, endpoint and keys from Penpot's configuration guide. We did not test that.
To restore, as penpot, with the backup in ~/backup:
systemctl --user start penpot-db
systemctl --user stop penpot-backend penpot-exporter penpot-frontend
podman volume exists penpot-assets && podman volume rm penpot-assets
podman volume create --opt o=uid=1001,gid=1001 penpot-assets
podman volume import penpot-assets ~/backup/penpot-assets.tar
podman exec penpot-db dropdb -U penpot penpot
podman exec penpot-db createdb -U penpot penpot
podman exec -i penpot-db psql -q -U penpot -d penpot < ~/backup/penpot-db.sql > /dev/null
systemctl --user start penpot-backend penpot-exporter penpot-frontend
Starting the database starts the whole pod, so the second line stops the rest before the data is replaced. uid=1001 is the user the backend runs as inside its container; without it, the new volume belongs to root, and exports fail with unable-to-upload-resource in the exporter's log.
On a new server, follow steps 1 to 6, but create the secret key from the backup instead of a new one in step 2, and use the ~/penpot.env from step 5 straight away. Then restore as above:
tr -d '\n' < ~/backup/secret-key.txt | podman secret create penpot-secret-key -
We restored this way onto a new penpot user with empty volumes: the files, comments, images and accounts were all there, and people who were logged in stayed logged in.
16. Update
podman auto-update checks once a day for new images and restarts the pod when one has changed. With the 2.18 tags, that covers Penpot's bug-fix releases, along with PostgreSQL 17 and Valkey 8.1. To update now:
podman auto-update
A new Penpot version, such as 2.19, gets a new tag. Read its release notes, back up as in step 15, then change the tag in the three Penpot files and restart:
cd ~/.config/containers/systemd
sed -i 's/:2\.18$/:2.19/' penpot-backend.container penpot-frontend.container penpot-exporter.container
systemctl --user daemon-reload
systemctl --user restart penpot-pod
podman image prune -a -f
The backend migrates the database by itself when it starts. Our update from 2.17.2 to 2.18.3 took two minutes, most of it downloading, and podman logs penpot-backend listed the two migrations it applied. podman image prune -a removes the images no container uses any more, here the old version's, about 3 GB.
Troubleshooting
The sign-up page in the tunnel keeps loading. PENPOT_PUBLIC_URI is already the public address, so the app sends its requests to https://design.example.com, which does not answer yet. Set it to http://localhost:8120 as in step 3, and restart the pod.
An invited person logs in but does not join the team. Their account did not exist when the invitation was sent. Send the invitation again from the Invitations tab.
Exports stop working after a restore, and the exporter's log says unable-to-upload-resource. The penpot-assets volume belongs to root. Recreate it with --opt o=uid=1001,gid=1001 as in step 15.
A flag does not take effect. The flags are read when the containers start. Restart the pod with systemctl --user restart penpot-pod, and reload the page.