# Can I self-host Figma?

**YES, IF** — it's called Penpot. ONGOING OPS setup · ~4 hours to running · 4 GB RAM minimum · $60/mo you stop paying ($720/yr on the Professional plan, 3 seats assumed).

Penpot authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/figma/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below Penpot for this swap. The prompts above install Penpot only.

- **Excalidraw** — 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 page this mirrors: https://caniselfhostit.com/self-host/figma/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
