Can I self-host Figma?
YES, IF · ONGOING OPS— setup effort 4 of 4YES, IF — it's called Penpot. It takes one prompt, a 4096 MB VPS, and about 240 minutes. That is $60 a month you stop paying Figma — $720 a year on the Professional plan, 3 seats assumed.
Why people pay for Figma
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Figma sells the place the work already is. One link that is always the current version, the comment sitting on the frame it is about, a handoff an engineer opens without asking anyone for a file, and a browser tab that runs a real vector editor fast enough that nobody thinks about the browser. The per-seat bill is mostly the price of never again asking which copy is the live one.
| Plan | List price | What it buys |
|---|---|---|
| Starter | free | Free, with limited access across the Figma products and an AI allowance the page states as 150 credits a day, up to 500 a month. |
| Professionalthe plan this page prices against | $20/mo per seat | A Full seat billed monthly. On annual billing it is $16, which is the figure the pricing page shows first. Dev seats are $15 monthly and $12 annual; Collab seats are $5 monthly and $3 annual. |
| Organization | $55/mo per seat | A Full seat. Sold on annual billing only, so $55 is the annual rate expressed per month rather than a month-to-month price. Dev seats are $25 and Collab seats $5 on the same terms. |
| Enterprise | $90/mo per seat | A Full seat, annual billing only, so again an annual rate expressed per month. Dev seats are $35 and Collab seats $5. Every paid plan lets unlimited extra people view and comment without a seat. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Penpot
One project, named before the prompt, so you know what you are about to install.
Components, prototyping and more than one cursor on the same board, on a domain you own and with no per-seat bill.
The only self-hosted option here that is the same category of tool. Components and variants, auto layout, prototyping links, a code-inspection panel for handoff, and more than one cursor on the same board, none of it behind a paid tier or a seat count. The cost is real: five services and a PostgreSQL you now operate, so this is the heaviest install on the design shelf. What you give up is Figma's plugin catalogue and its bundled licensed fonts, not the parts of the job the seats were for.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Penpotour pickONGOING OPS— setup effort 4 of 4
Components, prototyping and more than one cursor on the same board, on a domain you own and with no per-seat bill.
The only self-hosted option here that is the same category of tool. Components and variants, auto layout, prototyping links, a code-inspection panel for handoff, and more than one cursor on the same board, none of it behind a paid tier or a seat count. The cost is real: five services and a PostgreSQL you now operate, so this is the heaviest install on the design shelf. What you give up is Figma's plugin catalogue and its bundled licensed fonts, not the parts of the job the seats were for.
- ExcalidrawONE COMMAND— setup effort 1 of 4
A hand-drawn-style whiteboard that runs as one static container and keeps every drawing in the browser that drew it.
Second because it answers a different half of the same bill, and answers it honestly. If the seat you actually resented was the one somebody used for whiteboarding, this is a single static container and you are finished in ten minutes. It is not a design tool: no components, no auto layout, no prototyping, and in the self-hosted image no realtime collaboration and no server-side file store either, so drawings live in the browser that made them. Pick it when the sketching was the point, not when the design system was.
The swap
You'd run
Penpot
ONGOING OPS · ~240 min to running · 4096 MB RAM
Figma Professional · 3 seats assumed · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~240 min3+ hours, then ongoing, through the first backup
The prompt
Two paths to the same Penpot: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
347 lines · 14,784 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Penpot 2.17.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for it once and stop until they answer. Say why: it
becomes `PENPOT_PUBLIC_URI`, and Penpot builds every share link, invitation and export URL from
it, so a board link already in somebody's chat window dies if it changes. Its A record must
point here now.
Penpot needs 4096 MB of RAM available and 20 GB free on /srv; upstream's own answer is 1 to 2
CPUs and 4 GiB. All five images publish amd64 and arm64.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 4096 MB or free disk under 20 GB, print both and stop. If `dig +short`
prints nothing, print that and stop. Do not install and hope.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/penpot /srv/penpot/backups
sudo install -d -m 700 /srv/penpot/postgres
sudo install -d -m 750 -o 1001 -g 1001 /srv/penpot/assets
ls -la /srv/penpot
```
Assert: `ls -la` shows `backups` owned by the login user, `postgres` at mode `700` owned by
root, `assets` owned by `1001`. Leave all three. PostgreSQL chowns its own data directory on
first start; backend and frontend run as uid 1001 and share `assets`, so chowning that one to
the login user makes uploads fail on a permission error nothing explains.
## 3. Secrets
Two: the master key Penpot derives session and invitation keys from, and the PostgreSQL
password. Generate both on the server, print neither, keep both out of your summary and out of
every log line. Hex not base64: `openssl rand -base64 64` wraps onto two lines and an env file
is read one line at a time.
```bash
umask 077
cat > /srv/penpot/.env <<EOF
PENPOT_PUBLIC_URI=https://<DOMAIN>
PENPOT_FLAGS=enable-registration disable-email-verification enable-prepl-server
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/penpot/.env
umask 022
ls -l /srv/penpot/.env
```
Assert: mode `-rw-------`. That key is 512 bits, the size upstream asks for; losing it logs
every session out and voids every outstanding invitation, so step 8 gets a copy off the box. The
three flags: registration is open only until step 7 closes it; email verification is off because
this install runs no SMTP and an account nobody verified can still log in; the prepl server is
the local socket the backend's CLI talks to, the one way back in if a password is forgotten.
## 4. compose.yml
```bash
cat > /srv/penpot/compose.yml <<'EOF'
# Penpot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ... https://help.penpot.app/technical-guide/getting-started/docker/
# configuration .... https://help.penpot.app/technical-guide/configuration/
# sizing + valkey .. https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# flag definitions . https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Five services: nginx plus the browser app, the API and file data, an exporter
# rendering in a headless Chromium inside its own image, PostgreSQL for the
# designs, Valkey for websocket notifications.
#
# Upstream's compose runs two more this file leaves out: an MCP server, routed
# by the frontend only when PENPOT_FLAGS contains enable-mcp, and a mailcatcher,
# a development mailbox. Telemetry is off here; upstream's compose turns it on.
#
# Digests read from Docker Hub on 2026-08-06; all five publish amd64 and arm64.
# Backend and frontend run as uid 1001, which is why /srv/penpot/assets is too.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: penpot
services:
penpot-postgres:
image: postgres:15.18@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: unless-stopped
stop_signal: SIGINT
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- /srv/penpot/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot -d penpot"]
interval: 10s
retries: 30
# No `ports:`: 5432 is reachable only from the other containers.
penpot-valkey:
image: valkey/valkey:8.1.9-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
# Arguments rather than upstream's env var; numbers from their docs.
command: ["valkey-server", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-lfu"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 5s
retries: 20
penpot-backend:
image: penpotapp/backend:2.17.0@sha256:471cdebf185be899ef7d7593e9cd7994b908ebd7ffb78ca547e3d843bb83536f
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${DB_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_TELEMETRY_ENABLED: "false"
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
penpot-exporter:
image: penpotapp/exporter:2.17.0@sha256:7e8beb6ef2bdb9d778e9bbcbf7feebf8c99a137b2d9eb3969450c0a1a49e41c5
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
depends_on:
penpot-valkey:
condition: service_healthy
penpot-frontend:
image: penpotapp/frontend:2.17.0@sha256:861989dfff50f12b9de1358c6b0f3cc1e601d7a678db2826f3643d0f93438500
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8122.
- "127.0.0.1:8122:8080"
depends_on:
- penpot-backend
- penpot-exporter
EOF
cd /srv/penpot && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Every `${...}` above is filled from /srv/penpot/.env, which compose reads
because the command runs in that directory.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy first: a syntax error
here takes down every other site.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-penpot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Penpot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://help.penpot.app/technical-guide/getting-started/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PENPOT_PUBLIC_URI in .env; Penpot builds every share and export URL from it.
<DOMAIN> {
# The frontend image already sends nosniff, Referrer-Policy,
# Permissions-Policy and X-Frame-Options SAMEORIGIN, so repeating them
# here would send each twice. HSTS is the one it cannot set: only this
# block knows the name is served over TLS. No `encode` either, because
# that nginx gzips its own responses.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8122 is the loopback port compose publishes here. Not a container port,
# not open in the firewall. reverse_proxy passes the /ws/notifications
# upgrade through untouched, which is how cursors move.
reverse_proxy 127.0.0.1:8122
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-penpot, reload, and report
what it objected to. Caddy gets the certificate on the first request and renews it itself.
## 6. Firewall
Two ports open, both Caddy's, idempotent on a Prompt Zero box:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects, 443/tcp is the way in, 443/udp is HTTP/3. 8122
stays closed because compose binds it to 127.0.0.1; 5432, 6379, 6060, 6061 and the CLI socket on
6063 because nothing publishes them. Assert: `Status: active`, rules for 80, 443/tcp and
443/udp, none for those five.
## 7. Start and verify
The pull is about 1.4 GB, most of it the exporter's browser, and the backend then migrates its
database before it answers anything.
```bash
cd /srv/penpot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/readyz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/readyz
curl -sS https://<DOMAIN>/ | grep -c 'Penpot | Full-stack design'
curl -sS https://<DOMAIN>/js/config.js
docker compose ps
```
Assert all five, printing what you got. The loop ends on `200`. `/readyz` prints `OK` and earns
it: that handler runs a real query against PostgreSQL, so 200 means backend and database are
both up. The grep prints `1`, from the `<title>` Penpot serves. `config.js` contains
`var penpotFlags = "enable-registration disable-email-verification enable-prepl-server";`, where
the browser reads what this install allows. `ps` shows all five services `Up`. If any
misses, stop, run `docker compose logs --tail 40 penpot-backend`, and name the cause: a backend
that never starts is step 3 and an empty `DB_PASSWORD`; a `502` from Caddy is the frontend still
waiting on its dependencies. A running container is not success.
The first screen at https://<DOMAIN> shows the heading `Log into my account` with a
`Create an account` link under it.
STOP: tell the user to open https://<DOMAIN>, click `Create an account`, register with their
email and a password they save in their password manager first, and wait. Do not continue until
they confirm. No SMTP means no reset mail, so that saved password is the way in.
Once they confirm, close registration and recreate the two containers that read the flag:
```bash
sed -i 's/^PENPOT_FLAGS=enable-registration/PENPOT_FLAGS=disable-registration/' /srv/penpot/.env
cd /srv/penpot && docker compose up -d --force-recreate penpot-backend penpot-frontend
sleep 20
curl -sS https://<DOMAIN>/js/config.js | grep -c 'disable-registration'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/readyz
```
Assert: `1` and `200`.
STOP: tell the user to reload https://<DOMAIN> in a private window and confirm the
`Create an account` link is gone, and wait. Both asserts and that confirmation must pass before
you report success.
## 8. First backup and restore
Two artifacts: the database holds every file, board, comment and account; the config archive
holds the uploaded images and fonts plus the `.env` those sessions depend on.
```bash
cd /srv/penpot
docker compose exec -T penpot-postgres pg_dump -U penpot -d penpot | gzip > /srv/penpot/backups/penpot-db-$(date +%F).sql.gz
sudo tar -czf /srv/penpot/backups/penpot-config-$(date +%F).tar.gz -C /srv/penpot compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh /srv/penpot/backups/
```
Assert: both exist, both non-empty, print both sizes. Nothing stops, because `pg_dump` snapshots
a running database consistently. Valkey holds notifications in flight and nothing that outlives
a restart, so it is not backed up. A backup on the same disk is not a backup, so run this from
the user's machine:
```bash
mkdir -p ~/backups/penpot
scp vps:/srv/penpot/backups/* ~/backups/penpot/
```
To restore: `docker compose down`, `sudo rm -rf /srv/penpot/postgres`, recreate it as in step 2,
untar the config archive into /srv/penpot so `.env` and `assets` are back first,
`docker compose up -d penpot-postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T penpot-postgres psql -U penpot -d penpot`, then `docker compose up -d`.
Say the stakes: those two files are one backup, because a database restored beside a different
`PENPOT_SECRET_KEY` logs everyone out and one restored without `assets` opens every board with
the images missing.
## 9. Updating later
Releases are at https://github.com/penpot/penpot/releases. Back both artifacts up first, then
edit the three `penpotapp/` image lines in compose.yml to the new tag and digest. They move
together: a frontend newer than its backend fails in the browser, not in a log.
```bash
cd /srv/penpot
docker compose pull
docker compose up -d
docker compose logs --tail 40 penpot-backend
```
The backend migrates its database on the way up, so watch that log settle, then re-run step 7's
`/readyz` check before calling the update done.
## 10. What will probably go wrong
The wait, twice over. I ran `docker compose up -d`, watched `/readyz` answer `502` for four
minutes, and went looking for a fault that was not there. The exporter image alone is 641 MB
compressed because it carries a headless Chromium, so on a cold pull the stack is still arriving
while compose claims to have started it, and then the backend runs its migrations before it
answers anything. Let step 7's loop run all forty times before concluding anything is broken;
`docker compose logs -f penpot-backend` is what is worth watching meanwhile.
## 11. Out of scope
- Do not configure SMTP. `disable-email-verification` is what lets this install run without it,
and invitations still work: the invite link is on screen for the person who sent it.
- Do not add `enable-mcp` or the MCP container. The frontend routes to it only when that flag is
set, and it is a separate service with its own trust decision.
- Do not switch object storage to S3, and do not enable OIDC, Google, GitHub or GitLab login.
Each is a second account somewhere else, and this install has none on purpose.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Penpot 2.17.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.
Read this before step 1. `<DOMAIN>` becomes `PENPOT_PUBLIC_URI`, and Penpot builds every share
link, team invitation and export URL from it. Change it later and a board link already sitting
in somebody's chat window stops working. Pick the hostname you intend to keep.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line. Upstream's own answer to what Penpot needs is 1 to 2 CPUs and
4 GiB, and all five images publish both architectures.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve and failed attempts count against a rate limit you cannot see. Under 4096 MB is the one
number not to argue with: this is five services, one of them a JVM and one of them a browser,
and the OOM killer arrives during your first export rather than during the install.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/penpot /srv/penpot/backups
sudo install -d -m 700 /srv/penpot/postgres
sudo install -d -m 750 -o 1001 -g 1001 /srv/penpot/assets
ls -la /srv/penpot
```
You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`assets` owned by `1001`.
If you do not: leave all three as they are, on purpose. The PostgreSQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. The Penpot backend and frontend both run as uid 1001 and share `assets` between
them, so if you chown that one to yourself every image upload fails with a permission error and
nothing in the interface tells you why.
## 3. Secrets
Two: the master key Penpot derives session and invitation keys from, and the PostgreSQL
password. Both are generated here, on the server, and both go straight into a file only you can
read. Hex rather than base64, because `openssl rand -base64 64` wraps onto two lines and an env
file is read one line at a time.
```bash
umask 077
cat > /srv/penpot/.env <<EOF
PENPOT_PUBLIC_URI=https://<DOMAIN>
PENPOT_FLAGS=enable-registration disable-email-verification enable-prepl-server
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/penpot/.env
umask 022
ls -l /srv/penpot/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.
Do not paste that file, either secret, or any command output containing them into this chat
window. The agent path never sees those values; this one hands them to a third party unless you
keep them out.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines in separate shells. Run `chmod 600 /srv/penpot/.env` and carry on. If the
file already existed from an earlier attempt, this block has now replaced both secrets, which is
fine before the database exists and a problem afterwards: PostgreSQL keeps the password it was
created with, so a changed `DB_PASSWORD` against an existing volume shows up as an
authentication failure in the backend log rather than as anything about passwords.
Those three flags are the security shape of this install. Registration is open only until step 7
closes it. Email verification is off because there is no SMTP server here, and an account nobody
verified can still log in. The prepl server is a socket on localhost inside the backend
container, which is what its own command-line tool talks to, and it is your way back in if you
ever lose the password.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/penpot/compose.yml <<'EOF'
# Penpot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ... https://help.penpot.app/technical-guide/getting-started/docker/
# configuration .... https://help.penpot.app/technical-guide/configuration/
# sizing + valkey .. https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# flag definitions . https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Five services: nginx plus the browser app, the API and file data, an exporter
# rendering in a headless Chromium inside its own image, PostgreSQL for the
# designs, Valkey for websocket notifications.
#
# Upstream's compose runs two more this file leaves out: an MCP server, routed
# by the frontend only when PENPOT_FLAGS contains enable-mcp, and a mailcatcher,
# a development mailbox. Telemetry is off here; upstream's compose turns it on.
#
# Digests read from Docker Hub on 2026-08-06; all five publish amd64 and arm64.
# Backend and frontend run as uid 1001, which is why /srv/penpot/assets is too.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: penpot
services:
penpot-postgres:
image: postgres:15.18@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: unless-stopped
stop_signal: SIGINT
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- /srv/penpot/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot -d penpot"]
interval: 10s
retries: 30
# No `ports:`: 5432 is reachable only from the other containers.
penpot-valkey:
image: valkey/valkey:8.1.9-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
# Arguments rather than upstream's env var; numbers from their docs.
command: ["valkey-server", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-lfu"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 5s
retries: 20
penpot-backend:
image: penpotapp/backend:2.17.0@sha256:471cdebf185be899ef7d7593e9cd7994b908ebd7ffb78ca547e3d843bb83536f
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${DB_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_TELEMETRY_ENABLED: "false"
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
penpot-exporter:
image: penpotapp/exporter:2.17.0@sha256:7e8beb6ef2bdb9d778e9bbcbf7feebf8c99a137b2d9eb3969450c0a1a49e41c5
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
depends_on:
penpot-valkey:
condition: service_healthy
penpot-frontend:
image: penpotapp/frontend:2.17.0@sha256:861989dfff50f12b9de1358c6b0f3cc1e601d7a678db2826f3643d0f93438500
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8122.
- "127.0.0.1:8122:8080"
depends_on:
- penpot-backend
- penpot-exporter
EOF
cd /srv/penpot && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal, so run `rm /srv/penpot/compose.yml` and paste again in one go. A warning that
`PENPOT_SECRET_KEY` is not set means step 3 did not write the file, or you are not in
/srv/penpot: compose fills every `${...}` from the `.env` in the directory you run it from.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-penpot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Penpot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://help.penpot.app/technical-guide/getting-started/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PENPOT_PUBLIC_URI in .env; Penpot builds every share and export URL from it.
<DOMAIN> {
# The frontend image already sends nosniff, Referrer-Policy,
# Permissions-Policy and X-Frame-Options SAMEORIGIN, so repeating them
# here would send each twice. HSTS is the one it cannot set: only this
# block knows the name is served over TLS. No `encode` either, because
# that nginx gzips its own responses.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8122 is the loopback port compose publishes here. Not a container port,
# not open in the firewall. reverse_proxy passes the /ws/notifications
# upgrade through untouched, which is how cursors move.
reverse_proxy 127.0.0.1:8122
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-penpot /etc/caddy/Caddyfile`, reload,
and paste again. The block deliberately sets only two headers, because Penpot's own frontend
already sends nosniff, Referrer-Policy, Permissions-Policy and X-Frame-Options on every
response, and adding them here would send each one twice.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8122`, `5432`, `6379`, `6060`, `6061` or `6063`.
If you do not: delete anything for those with `sudo ufw delete allow 8122`. 8122 is bound to
127.0.0.1 by the compose file, the database and Valkey publish no host port at all, the backend
and exporter are reachable only over the container network, and 6063 is the backend's own
command-line socket bound to localhost inside its container. 80/tcp is there to redirect to
HTTPS and answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which
Caddy offers by default. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it back.
## 7. Start and verify
About 1.4 GB of images arrive here, most of it the exporter's headless Chromium, and the backend
runs its own database migrations before it answers anything. The loop below waits ten minutes.
```bash
cd /srv/penpot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/readyz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/readyz
curl -sS https://<DOMAIN>/ | grep -c 'Penpot | Full-stack design'
curl -sS https://<DOMAIN>/js/config.js
docker compose ps
```
You should see, in order: the loop reaching `200`; the word `OK`; the number `1`; a short file
containing
`var penpotFlags = "enable-registration disable-email-verification enable-prepl-server";`; and
five services listed as `Up`.
If you do not: `OK` is the one worth understanding. That endpoint runs a real query against
PostgreSQL before it answers, so a `200` there means the backend and the database are both up
and talking to each other, which is most of this install. A `502` from Caddy while the loop is
still running is normal and means the frontend has not finished waiting on its own dependencies;
run `docker compose logs --tail 40 penpot-backend` if the loop reaches forty without a `200`. An
empty result from the `config.js` line means the frontend started before `.env` existed, so
`docker compose up -d --force-recreate penpot-frontend` and look again.
Now open https://<DOMAIN> in a browser. The first screen shows the heading
`Log into my account` with a `Create an account` link under it. Click it, register with your
email address, and save the password in your password manager before you submit: there is no
SMTP server here, so no reset email will ever arrive.
Then close registration behind you:
```bash
sed -i 's/^PENPOT_FLAGS=enable-registration/PENPOT_FLAGS=disable-registration/' /srv/penpot/.env
cd /srv/penpot && docker compose up -d --force-recreate penpot-backend penpot-frontend
sleep 20
curl -sS https://<DOMAIN>/js/config.js | grep -c 'disable-registration'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/readyz
```
You should see: `1`, then `200`. Reload https://<DOMAIN> in a private window and confirm the
`Create an account` link is gone.
If you do not: a `0` from the grep means the frontend was not recreated, so run the second line
again and check `docker compose ps` shows a fresh created time. Do not skip this step because
the box is new and nobody knows the hostname yet. An open registration page on a public name is
found by scanners in hours, and everyone who registers lands in your instance. From here, new
people arrive by team invitation instead.
## 8. First backup and restore
Two artifacts. The database holds every file, board, comment and account. The config archive
holds the images and fonts you upload, plus the `.env` whose key those sessions depend on.
```bash
cd /srv/penpot
docker compose exec -T penpot-postgres pg_dump -U penpot -d penpot | gzip > /srv/penpot/backups/penpot-db-$(date +%F).sql.gz
sudo tar -czf /srv/penpot/backups/penpot-config-$(date +%F).tar.gz -C /srv/penpot compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh /srv/penpot/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently. Valkey is not backed up, because it holds
notifications in flight and nothing that outlives a restart.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/penpot
scp vps:/srv/penpot/backups/* ~/backups/penpot/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/penpot/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty account:
```bash
cd /srv/penpot
docker compose down
sudo rm -rf /srv/penpot/postgres
sudo install -d -m 700 /srv/penpot/postgres
docker compose up -d penpot-postgres
sleep 30
gunzip -c /srv/penpot/backups/penpot-db-$(date +%F).sql.gz | docker compose exec -T penpot-postgres psql -U penpot -d penpot
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/readyz
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `200`, then your own login
working in the browser with the password you already have.
If you do not: `role "penpot" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the two files are
before you skip this. They are one backup: a database restored beside a different
`PENPOT_SECRET_KEY` logs everyone out, and one restored without `assets` opens every board with
the images missing.
## 9. Updating later
New versions are listed at https://github.com/penpot/penpot/releases. Take both backup artifacts
first, then edit the three `penpotapp/` image lines in /srv/penpot/compose.yml to the new tag and
its digest. All three move together.
```bash
cd /srv/penpot
docker compose pull
docker compose up -d
docker compose logs --tail 40 penpot-backend
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tags and digests back and run the same three commands. A frontend
newer than its backend does not fail in a log, it fails in the browser, so re-run step 7's
`/readyz` check and then open a real file before you call the update done.
## 10. What will probably go wrong
The wait, twice over. I ran `docker compose up -d`, watched `/readyz` answer `502` for four
minutes, and went looking for a fault that was not there. The exporter image alone is 641 MB
compressed because it carries a headless Chromium, so on a cold pull the stack is still arriving
while compose claims to have started it, and then the backend runs its migrations before it
answers anything. Let step 7's loop run all forty times before concluding anything is broken;
`docker compose logs -f penpot-backend` is what is worth watching meanwhile.
## 11. Out of scope
- Do not configure SMTP. `disable-email-verification` is what lets this install run without it,
and invitations still work: the invite link is on screen for the person who sent it.
- Do not add `enable-mcp` or the MCP container. The frontend routes to it only when that flag is
set, and it is a separate service with its own trust decision.
- Do not switch object storage to S3, and do not enable OIDC, Google, GitHub or GitLab login.
Each is a second account somewhere else, and this install has none on purpose.362 lines · 16,461 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Penpot 2.17.0, with the PostgreSQL and Valkey it needs, under ~/selfhost/penpot,
answering at http://localhost:8122.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
Penpot is built around people working in the same file, and here nobody else can reach it: every
board and share link begins with http://localhost:8122, which means "this computer" wherever it
is read. What they get is a full design tool for one person.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Penpot needs 4096 MB of RAM available and
20 GB free on the home disk; all five images publish amd64 and arm64. On macOS and Windows that
figure is the host's, and Docker Desktop's machine takes its slice out of it. Under either
floor, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/penpot/assets ~/selfhost/penpot/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 1001:1001 ~/selfhost/penpot/assets; fi
ls -la ~/selfhost/penpot
```
Assert: `ls -la` shows `assets` and `backups`. Backend and frontend both run as uid 1001 and
share `assets`, so on Linux it is chowned to 1001 or uploads fail on a permission error; on macOS
and Windows that fence is a no-op. There is no `data` folder: the designs are PostgreSQL rows in a
volume Docker manages.
## 4. Secrets
Two: the master key Penpot derives session and invitation keys from, and the PostgreSQL
password. Generate both here, print neither, keep both out of your summary and out of every log
line. Hex, not base64, because `openssl rand -base64 64` wraps onto two lines.
```bash
umask 077
cat > ~/selfhost/penpot/.env <<EOF
PENPOT_PUBLIC_URI=http://localhost:8122
PENPOT_FLAGS=enable-registration disable-email-verification disable-secure-session-cookies enable-prepl-server
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/penpot/.env
umask 022
ls -l ~/selfhost/penpot/.env
```
Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three; on Windows
the mode bits are advisory and the real boundary is the user's own account. The key is 512 bits,
the size upstream asks for. The flags: registration is open until step 7 closes it; verification
is off because nothing here sends mail; secure session cookies are off because upstream's own
compose turns them off on a plain http address; the prepl server is the local socket the
backend's CLI uses, the way back in if a password is forgotten.
## 5. compose.yml
```bash
cat > ~/selfhost/penpot/compose.yml <<'EOF'
# Penpot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ... https://help.penpot.app/technical-guide/getting-started/docker/
# configuration .... https://help.penpot.app/technical-guide/configuration/
# sizing + valkey .. https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# flag definitions . https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Five services, every path relative to ~/selfhost/penpot/ so one file works on
# macOS, Linux and Windows. The database is a named volume because PostgreSQL
# chowns its data directory to a uid Docker Desktop cannot grant on a home
# bind mount; assets stay a bind mount, chowned to 1001 on Linux, the uid the
# backend and frontend run as. Upstream also runs an MCP server and a
# mailcatcher; both are omitted here, and telemetry is off. Digests read from
# Docker Hub 2026-08-06, all five multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: penpot
services:
penpot-postgres:
image: postgres:15.18@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: unless-stopped
stop_signal: SIGINT
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- penpot-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot -d penpot"]
interval: 10s
retries: 30
# No `ports:`: 5432 is reachable only from the other containers.
penpot-valkey:
image: valkey/valkey:8.1.9-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
# Arguments rather than upstream's env var; numbers from their docs.
command: ["valkey-server", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-lfu"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 5s
retries: 20
penpot-backend:
image: penpotapp/backend:2.17.0@sha256:471cdebf185be899ef7d7593e9cd7994b908ebd7ffb78ca547e3d843bb83536f
restart: unless-stopped
volumes:
- ./assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${DB_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_TELEMETRY_ENABLED: "false"
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
penpot-exporter:
image: penpotapp/exporter:2.17.0@sha256:7e8beb6ef2bdb9d778e9bbcbf7feebf8c99a137b2d9eb3969450c0a1a49e41c5
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
depends_on:
penpot-valkey:
condition: service_healthy
penpot-frontend:
image: penpotapp/frontend:2.17.0@sha256:861989dfff50f12b9de1358c6b0f3cc1e601d7a678db2826f3643d0f93438500
restart: unless-stopped
volumes:
- ./assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
ports:
# Loopback only: no other device on the wifi can reach 8122.
- "127.0.0.1:8122:8080"
depends_on:
- penpot-backend
- penpot-exporter
volumes:
penpot-pgdata:
EOF
cd ~/selfhost/penpot && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Every `${...}` is filled from the .env in that directory.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. No hostname, so
nothing to resolve. No certificate, because one attests a public name and nothing here has one;
browsers treat http://localhost as a secure context anyway, so pages needing crypto still work.
No firewall rule, because nothing is published beyond loopback: 8122 is bound to 127.0.0.1, this
computer only, not the user's phone, not a laptop on the wifi, not anyone on the internet. For a
tool built around shared files that is the trade, not a defect. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/penpot/compose.yml
```
Assert: one line, `- "127.0.0.1:8122:8080"`. The other four publish no host port at all.
## 7. Start and verify
The pull is about 1.4 GB, mostly the exporter's browser; the backend then migrates its database
before answering.
```bash
cd ~/selfhost/penpot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8122/readyz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8122/readyz
curl -sS http://localhost:8122/ | grep -c 'Penpot | Full-stack design'
curl -sS http://localhost:8122/js/config.js
```
Assert all four, printing what you got: the loop ends on `200`; `/readyz` prints `OK`, earned by
a real query against PostgreSQL; the grep prints `1`, from the `<title>` Penpot serves;
`config.js` carries step 4's flags, `enable-registration` among them. If any misses, stop, run
`docker compose logs --tail 40 penpot-backend`, and name the cause: a backend that never starts
is step 4 and an empty `DB_PASSWORD`; `port is already allocated` means something else holds
8122 and the user has to free it. A running container is not success.
The first screen at http://localhost:8122 shows the heading `Log into my account` with a
`Create an account` link under it.
STOP: tell the user to open http://localhost:8122, click `Create an account`, and register with
any email address and a password they save in a password manager first. Nothing sends mail here,
so no reset message ever arrives. Do not continue until they confirm.
Then close registration and recreate the two containers that read the flag:
```bash
cd ~/selfhost/penpot
umask 077
sed 's/^PENPOT_FLAGS=enable-registration/PENPOT_FLAGS=disable-registration/' .env > .env.new && mv .env.new .env
umask 022
chmod 600 .env
docker compose up -d --force-recreate penpot-backend penpot-frontend
sleep 20
curl -sS http://localhost:8122/js/config.js | grep -c 'disable-registration'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8122/readyz
```
Assert: `1` and `200`. That write goes through a temporary file because `sed -i` takes an
argument on macOS and none on Linux.
STOP: tell the user to reload http://localhost:8122 in a private window and confirm the
`Create an account` link is gone. That, and both asserts, before you report success.
## 8. First backup and restore
Two artifacts: the database holds every file, board, comment and account; the archive holds the
uploads plus the `.env` those sessions depend on.
```bash
cd ~/selfhost/penpot
docker compose exec -T penpot-postgres pg_dump -U penpot -d penpot | gzip > ~/selfhost/penpot/backups/penpot-db-$(date +%F).sql.gz
tar -C ~/selfhost/penpot -czf ~/selfhost/penpot/backups/penpot-config-$(date +%F).tar.gz compose.yml .env assets
ls -lh ~/selfhost/penpot/backups/
```
Assert: both exist, both non-empty, print both sizes. Nothing stops: `pg_dump` snapshots a
running database consistently, and Valkey holds nothing that outlives a restart. If `tar` says
`Permission denied` on Linux, run that line again with `sudo`.
Both sit on the same disk as the data, which is not a backup; on a laptop the disk and the
machine fail together. Ask the user for a destination off this computer, a sync folder or a USB
stick, and copy both there with `cp`; in Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both filenames are there. If they have neither, say
plainly this install has no backup.
To restore, in this order. `cd ~/selfhost/penpot`, untar the config archive there first so
compose.yml, .env and assets are back before any container starts: PostgreSQL reads
`DB_PASSWORD` from .env the moment it initialises an empty volume. Then `docker compose down -v`,
the one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d penpot-postgres`, about 30 seconds for healthy, `gunzip -c` on the
`.sql.gz` piped into `docker compose exec -T penpot-postgres psql -U penpot -d penpot`, then
`docker compose up -d`, then log in and open a file. Those two files are one backup: a database
restored beside a different `PENPOT_SECRET_KEY` logs everyone out, and one restored without
`assets` opens every board with the images missing.
## 9. Updating later
Releases are at https://github.com/penpot/penpot/releases. Take both backups first, then edit
the three `penpotapp/` image lines in compose.yml to the new tag and digest, as one.
```bash
cd ~/selfhost/penpot
docker compose pull
docker compose up -d
docker compose logs --tail 40 penpot-backend
```
The backend migrates its database on the way up. Watch that log settle, then re-run step 7's
check.
## 10. What will probably go wrong
I exported a board to PDF on a laptop, got nothing for a long minute, and assumed the exporter
had died. It had not. That container starts a headless Chromium out of a 641 MB image, and on
macOS and Windows it does so inside Docker Desktop's virtual machine, which gets its own slice of
memory rather than the host figure step 1 measured. If exports hang while the rest of Penpot
feels fine, open Docker Desktop, Settings, Resources and give it 4 GB. Editing works below that;
exporting is what does not.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `PENPOT_PUBLIC_URI` to a LAN address and do not rebind 8122 to 0.0.0.0 so a
colleague can join a file. That puts a tool whose session cookie is not marked secure onto
every network this computer joins.
- Do not configure SMTP, and do not add `enable-mcp` or an MCP container.compose.local.ymlthe services, pinned · local layout98 lines
# Penpot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ... https://help.penpot.app/technical-guide/getting-started/docker/
# configuration .... https://help.penpot.app/technical-guide/configuration/
# sizing + valkey .. https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# flag definitions . https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Five services, every path relative to ~/selfhost/penpot/ so one file works on
# macOS, Linux and Windows. The database is a named volume because PostgreSQL
# chowns its data directory to a uid Docker Desktop cannot grant on a home
# bind mount; assets stay a bind mount, chowned to 1001 on Linux, the uid the
# backend and frontend run as. Upstream also runs an MCP server and a
# mailcatcher; both are omitted here, and telemetry is off. Digests read from
# Docker Hub 2026-08-06, all five multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: penpot
services:
penpot-postgres:
image: postgres:15.18@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: unless-stopped
stop_signal: SIGINT
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- penpot-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot -d penpot"]
interval: 10s
retries: 30
# No `ports:`: 5432 is reachable only from the other containers.
penpot-valkey:
image: valkey/valkey:8.1.9-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
# Arguments rather than upstream's env var; numbers from their docs.
command: ["valkey-server", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-lfu"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 5s
retries: 20
penpot-backend:
image: penpotapp/backend:2.17.0@sha256:471cdebf185be899ef7d7593e9cd7994b908ebd7ffb78ca547e3d843bb83536f
restart: unless-stopped
volumes:
- ./assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${DB_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_TELEMETRY_ENABLED: "false"
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
penpot-exporter:
image: penpotapp/exporter:2.17.0@sha256:7e8beb6ef2bdb9d778e9bbcbf7feebf8c99a137b2d9eb3969450c0a1a49e41c5
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
depends_on:
penpot-valkey:
condition: service_healthy
penpot-frontend:
image: penpotapp/frontend:2.17.0@sha256:861989dfff50f12b9de1358c6b0f3cc1e601d7a678db2826f3643d0f93438500
restart: unless-stopped
volumes:
- ./assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
ports:
# Loopback only: no other device on the wifi can reach 8122.
- "127.0.0.1:8122:8080"
depends_on:
- penpot-backend
- penpot-exporter
volumes:
penpot-pgdata:agent-readable mirror: /self-host/figma.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned98 lines
# Penpot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ... https://help.penpot.app/technical-guide/getting-started/docker/
# configuration .... https://help.penpot.app/technical-guide/configuration/
# sizing + valkey .. https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# flag definitions . https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Five services: nginx plus the browser app, the API and file data, an exporter
# rendering in a headless Chromium inside its own image, PostgreSQL for the
# designs, Valkey for websocket notifications.
#
# Upstream's compose runs two more this file leaves out: an MCP server, routed
# by the frontend only when PENPOT_FLAGS contains enable-mcp, and a mailcatcher,
# a development mailbox. Telemetry is off here; upstream's compose turns it on.
#
# Digests read from Docker Hub on 2026-08-06; all five publish amd64 and arm64.
# Backend and frontend run as uid 1001, which is why /srv/penpot/assets is too.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: penpot
services:
penpot-postgres:
image: postgres:15.18@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: unless-stopped
stop_signal: SIGINT
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- /srv/penpot/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot -d penpot"]
interval: 10s
retries: 30
# No `ports:`: 5432 is reachable only from the other containers.
penpot-valkey:
image: valkey/valkey:8.1.9-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
# Arguments rather than upstream's env var; numbers from their docs.
command: ["valkey-server", "--maxmemory", "128mb", "--maxmemory-policy", "volatile-lfu"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 5s
retries: 20
penpot-backend:
image: penpotapp/backend:2.17.0@sha256:471cdebf185be899ef7d7593e9cd7994b908ebd7ffb78ca547e3d843bb83536f
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${DB_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_TELEMETRY_ENABLED: "false"
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
penpot-exporter:
image: penpotapp/exporter:2.17.0@sha256:7e8beb6ef2bdb9d778e9bbcbf7feebf8c99a137b2d9eb3969450c0a1a49e41c5
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
PENPOT_REDIS_URI: redis://penpot-valkey/0
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
depends_on:
penpot-valkey:
condition: service_healthy
penpot-frontend:
image: penpotapp/frontend:2.17.0@sha256:861989dfff50f12b9de1358c6b0f3cc1e601d7a678db2826f3643d0f93438500
restart: unless-stopped
volumes:
- /srv/penpot/assets:/opt/data/assets
environment:
PENPOT_FLAGS: ${PENPOT_FLAGS}
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8122.
- "127.0.0.1:8122:8080"
depends_on:
- penpot-backend
- penpot-exporterCaddyfilethe hostname and TLS26 lines
# Penpot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://help.penpot.app/technical-guide/getting-started/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PENPOT_PUBLIC_URI in .env; Penpot builds every share and export URL from it.
<DOMAIN> {
# The frontend image already sends nosniff, Referrer-Policy,
# Permissions-Policy and X-Frame-Options SAMEORIGIN, so repeating them
# here would send each twice. HSTS is the one it cannot set: only this
# block knows the name is served over TLS. No `encode` either, because
# that nginx gzips its own responses.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8122 is the loopback port compose publishes here. Not a container port,
# not open in the firewall. reverse_proxy passes the /ws/notifications
# upgrade through untouched, which is how cursors move.
reverse_proxy 127.0.0.1:8122
}install.shthe same install, no agent159 lines
#!/usr/bin/env bash
# Penpot · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=design.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://help.penpot.app/technical-guide/getting-started/docker/
# https://help.penpot.app/technical-guide/configuration/
# https://help.penpot.app/technical-guide/getting-started/recommended-settings/
# https://github.com/penpot/penpot/blob/2.17.0/common/src/app/common/flags.cljc
#
# Two secrets are generated here, on this machine: the 512-bit master key Penpot
# derives session and invitation keys from, and the PostgreSQL password. Both go
# into /srv/penpot/.env at mode 600 and neither is ever printed.
#
# DOMAIN_HOST becomes PENPOT_PUBLIC_URI. Penpot builds every share link,
# invitation and export URL from it, so choose it once.
#
# This script leaves registration OPEN so you can create the first account, then
# tells you the one command that closes it. Run that command today.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/penpot}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. design.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; five services want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The PostgreSQL image chowns its own data directory on first start, so that one
# stays root-owned. The backend and frontend both run as uid 1001 and share the
# assets directory, so that one is chowned to 1001 rather than to you.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 -o 1001 -g 1001 "$APP_DIR/assets"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: `openssl rand -base64 64` wraps onto two
# lines and an env file is read one line at a time. Read them later with
# sudo grep -E 'PENPOT_SECRET_KEY|DB_PASSWORD' /srv/penpot/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
PENPOT_PUBLIC_URI=https://${DOMAIN_HOST}
PENPOT_FLAGS=enable-registration disable-email-verification enable-prepl-server
PENPOT_SECRET_KEY=$(openssl rand -hex 64)
DB_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-penpot"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$(dirname "$0")/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and none of the app's are among them ----------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8122, 5432, 6379, 6060, 6061 and 6063 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# About 1.4 GB of images, most of it the exporter's headless Chromium. The
# backend then runs its own database migrations before it answers anything.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/readyz"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/readyz" || true)"
[ "$code" = "200" ] && break
sleep 15
done
[ "${code:-}" = "200" ] || die "/readyz answered ${code:-nothing}. Check: docker compose logs --tail 40 penpot-backend"
# /readyz runs a real query against PostgreSQL, so OK means both are up.
curl -sS "https://${DOMAIN_HOST}/readyz" | grep -c 'OK' >/dev/null \
|| die "/readyz answered 200 without OK. Check: docker compose logs --tail 40 penpot-backend"
curl -sS "https://${DOMAIN_HOST}/" | grep -c 'Penpot | Full-stack design' >/dev/null \
|| die "the page at https://${DOMAIN_HOST}/ is not Penpot's. Check: docker compose logs --tail 40 penpot-frontend"
# The browser reads what this install allows from this file.
curl -sS "https://${DOMAIN_HOST}/js/config.js" | grep -c 'enable-registration' >/dev/null \
|| die "config.js does not carry the flags from .env. Check: docker compose logs --tail 40 penpot-frontend"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T penpot-postgres pg_dump -U penpot -d penpot | gzip > "$APP_DIR/backups/penpot-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/penpot-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/penpot-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Penpot is answering at https://${DOMAIN_HOST}
1. Open it. The first screen reads "Log into my account" with a
"Create an account" link. Register now: registration is open, and
step 2 closes it.
2. Then close registration, from $APP_DIR:
sed -i 's/^PENPOT_FLAGS=enable-registration/PENPOT_FLAGS=disable-registration/' .env
docker compose up -d --force-recreate penpot-backend penpot-frontend
Reload the page in a private window: the "Create an account" link
must be gone. After that, people arrive by team invitation.
3. There is no SMTP server here, so there is no password-reset email.
Put your password in a manager. If it is ever lost, the way back is
docker compose exec -it penpot-backend python3 manage.py update-profile
which prompts for the new password without printing it.
4. Your master key and database password are in $APP_DIR/.env, mode 600.
Neither was printed here.
5. First backup written to $APP_DIR/backups: a database dump and a
config archive holding .env and the uploaded assets. They are one
backup in two files, and both are on the same disk as the data,
which is not a backup. Copy them somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Figma.
- This is a stack, not an app. Five services stay up: a frontend, a backend, an exporter carrying its own headless Chromium, PostgreSQL and Valkey. On any given morning one of them can be the unhappy one, and you are the only person who will notice.
- It is a different tool, not a clone of one. Components, prototyping, code inspection and more than one cursor on a board are all here and none of them sit behind a paid tier, but the plugin catalogue is small beside Figma's, and nothing in this install imports an existing Figma file. What you already have there stays there.
- Fonts are yours to bring. Penpot ships a snapshot of the Google Fonts catalogue and your own server fetches the font files from Google when a designer picks one, which one flag turns off entirely. Anything else, including whatever typeface your brand licenses, you upload yourself, and a file that used a font this instance does not have opens with a substitute.
- No mail, by choice. This install sets disable-email-verification, which is what lets it run with no SMTP server, and closes registration once the first account exists. It also means there is no password-reset email: the way back into a locked-out account is the backend's own command-line tool over a socket inside its container.
- The backup is two files that only work together. The database holds every board and account; the config archive holds the uploaded assets and the .env with PENPOT_SECRET_KEY in it. Restore the database beside a different key and everyone is logged out; restore it without the assets and every board opens with its images missing.
Where this came from
“if you're exposing Penpot to the internet, you should remove the flags 'disable-secure-session-cookies' and 'disable-email-verification'”
- Upstream's own compose file runs a frontend, a backend, an exporter, an MCP server, PostgreSQL, Valkey and a mailcatcher; this install keeps five of those and drops the last two. source
- Penpot's defaults enable registration, email verification and secure session cookies, and a default is switched off by naming the same flag with a disable- prefix in PENPOT_FLAGS. source
- The frontend container writes its MCP proxy routes only when PENPOT_FLAGS contains enable-mcp, which is why leaving that flag out also removes the need to run the MCP container. source
- The /readyz endpoint runs a query against PostgreSQL before it answers, returning 200 with the body OK, so it is one health check covering the backend and the database together. source
- Penpot's own answer to what a self-hosted instance needs is 1 to 2 CPUs and 4 GiB of RAM, with disk driven by the database and whatever users upload. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Figma?
Not Figma itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Penpot. Components, prototyping and more than one cursor on the same board, on a domain you own and with no per-seat bill. The install is ongoing ops: 5 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Figma?
Penpot. Components, prototyping and more than one cursor on the same board, on a domain you own and with no per-seat bill. The only self-hosted option here that is the same category of tool. Components and variants, auto layout, prototyping links, a code-inspection panel for handoff, and more than one cursor on the same board, none of it behind a paid tier or a seat count. The cost is real: five services and a PostgreSQL you now operate, so this is the heaviest install on the design shelf. What you give up is Figma's plugin catalogue and its bundled licensed fonts, not the parts of the job the seats were for. Also evaluated and ranked below it: Excalidraw. The prompt on this page installs Penpot only. Penpot is MPL-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Figma?
4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Penpot itself is free and MPL-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Figma Professional, $60/mo — $720 a year, 3 seats assumed.
How hard is it really?
ONGOING OPS — 3+ hours, then ongoing. The rule that produced that verdict: five or more containers. Five or more services is a stack. On any given morning one of them is unhappy, and you are the only person who is going to notice. The tier is derived from seven countable facts about the Penpot install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Penpot on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Penpot on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything Penpot is built for happens between people, and here the board answers at http://localhost:8122, which means this computer and nowhere else, so the colleague you wanted to review the design cannot open the link you send them. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.