skip to content
Sean Thawe
Table of Contents

I wanted a design tool on my own hardware, so I decided to self-host Penpot on my Raspberry Pi. Penpot ships official Docker images with arm64 support, so the Pi was never the hard part — the hard part was the configuration. The default compose file works, but only if you get three things right before you boot it, otherwise you’ll be staring at a UI that serves but refuses to talk to its own backend.

This guide covers the exact setup I used: the secrets to configure first, the one env var that breaks everything if you miss it, and the confusing 502s you’ll see while the backend restarts.

This method was tested on a Raspberry Pi running Raspberry Pi OS (64-bit) with Docker installed. Any Pi with Docker will work, but expect slow startup on 4GB models — make sure you have swap enabled.

What You’ll Achieve in This Guide

  • Run Penpot on a Raspberry Pi with the official Docker Compose file.
  • Configure the secret key and database password before first boot.
  • Fix the PENPOT_PUBLIC_URI trap that breaks the UI from other devices.
  • Understand why you get 502 errors right after the first start.

Part 1: Getting the Compose File Running

Penpot provides everything you need in one file. Grab it from their repo:

Terminal window
curl -o docker-compose.yaml https://raw.githubusercontent.com/penpot/penpot/main/docker/images/docker-compose.yaml

Then launch it exactly as the official docs say:

Terminal window
docker compose -p penpot -f docker-compose.yaml up -d

Explanation:

  • -p penpot: Names the Docker project so your containers get penpot- prefixes.
  • -f docker-compose.yaml: Points at the compose file.

The stack is six services: the frontend (nginx on port 9001), the backend, a Postgres database, Valkey for websocket notifications, an exporter for rendering, and a mailcatcher that swallows emails for testing.

Part 2: The Secrets to Configure Before First Boot

Do this before you run up -d. The default compose file ships with placeholder values, and you don’t want to change them after the backend has already derived its encryption keys.

2.1. The secret key

PENPOT_SECRET_KEY defaults to change-this-insecure-key. This is the master key everything else derives from. Generate a real one:

Terminal window
python3 -c "import secrets; print(secrets.token_urlsafe(64))"

2.2. The database password

The Postgres password appears twice in the file, and both spots must match:

  • POSTGRES_PASSWORD on the penpot-postgres service
  • PENPOT_DATABASE_PASSWORD on the penpot-backend service

The username stays penpot — it’s internal to the Docker network. Generate a random password the same way as above.

2.3. The public URI — the one that breaks everything

PENPOT_PUBLIC_URI defaults to http://localhost:9001. If you access Penpot from another device on your network, you must change it to the address you’ll actually use, for example:

PENPOT_PUBLIC_URI: http://<your-pi-ip>:9001

I learned this the hard way. The frontend loads fine from http://<your-pi-ip>:9001, but the app still thinks the API lives at localhost:9001 — which on your other device is nothing. Every request dies with CORS errors and the login screen never works.

2.4. The flags

The compose file ships with disable-secure-session-cookies in PENPOT_FLAGS so it works over plain HTTP. If you’re on HTTPS (for example behind Tailscale Serve), remove that flag so your session cookies only travel over TLS. If you’re staying on plain HTTP for now, leave it alone.

Part 3: First Boot, Migrations, and the 502s

First boot is slow. The Pi pulls four images, Postgres initializes, and the backend runs its database migrations — expect 1–3 minutes of the backend logging migration output before anything responds. Don’t restart the containers mid-migration or you risk a half-applied schema. Watch it with:

Terminal window
docker compose -p penpot logs -f

When the backend logs welcome to penpot, it’s ready.

One confusing thing you’ll see: after any change to the compose file, docker compose up -d recreates the backend, and for the ~30 seconds it takes the JVM to boot, every API call returns 502 Bad Gateway with a Connection refused in the frontend logs. That’s normal — the frontend’s nginx has nobody to talk to yet. Wait for the backend to finish booting and hard-refresh your browser.

The UI also throws a harmless 404 for /css/ui.css on first load — it’s a stale cache entry; the real stylesheet loads fine.


Part 4: Conclusion

You now have a self-hosted Penpot instance running on your Raspberry Pi. The three things that matter: a real PENPOT_SECRET_KEY, a matching database password, and a PENPOT_PUBLIC_URI that matches the URL you actually open. Get those right and everything else is smooth sailing.

For further reading, check out:

Further Reading

If you found this guide helpful, you might be interested in these other posts: