Your Design Files Live on Someone Else’s Server
Every design tool you pay for by the seat is a landlord. Your mockups, your component library, and three years of “final_v7_REAL” frames sit on infrastructure you can’t inspect, under a price list you can’t veto. Figma is a good landlord right now. That is exactly when you should read the lease.
Penpot is the open source answer, and my verdict is simple: self-hosting it is worth it if your team already runs Docker and you care about where design files live, and a waste of a weekend if you only want to save a few seats of cost. It runs as seven containers, it idles around 2.7 GiB of RAM, and its layout tools are built directly on CSS Flexbox and CSS Grid. Below is the stack, the settings that bite, and a plain list of what you give up.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/penpot-self-hosted-figma-alternative
What You Are Actually Running
The current release is Penpot 2.19.1, published on 2026-11-11. The official Compose file in the Penpot repository at that tag defines these services:
| Container | Job |
|---|---|
penpot-frontend | nginx. Serves the app and proxies the API. The only one of these seven with a published port. |
penpot-backend | The Clojure/JVM server. Files, users, the API. |
penpot-exporter | Renders exports of your designs. |
penpot-admin-console | New in the 2.18 and 2.19 era, enabled by default since 2.19. |
penpot-mcp | The MCP server, switched on by the enable-mcp flag. |
penpot-postgres | PostgreSQL 15. Everything durable. |
penpot-valkey | Valkey 8.1. Notifications and job queues, capped at 128 MB. |
The official file adds an eighth container, penpot-mailpit, a fake mail server for demos that also publishes port 8025. I dropped it in the kit, because a design tool that mails verification links to a dead-end inbox is a demo, not a deployment.
Two details are worth knowing before you touch anything. First, the Redis dependency is gone: the compose file now runs Valkey, but the backend variable is still called PENPOT_REDIS_URI, so redis://penpot-valkey/0 is correct. Valkey speaks the Redis protocol. Second, the Compose file at the 2.19.1 tag still defaults PENPOT_VERSION to 2.18. If you copy it and run up, you get the older release. Set the version yourself.
The Compose File
I started from the official file and changed a handful of things: secrets, the host port, the public URL, and the cookie flag come from .env, the image default is pinned to 2.19.1, mailpit and the enable-smtp flag are gone, and telemetry is off. The shared settings sit at the top as YAML anchors, so every service that needs the secret key or the public URL gets the same value.
x-flags: &penpot-flags PENPOT_FLAGS: >- disable-email-verification enable-prepl-server enable-mcp ${PENPOT_COOKIE_FLAG:-}
x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI:-http://localhost:9001}
x-secret-key: &penpot-secret-key PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY:?set PENPOT_SECRET_KEY in .env}The backend service is where the interesting variables live:
penpot-backend: image: "penpotapp/backend:${PENPOT_VERSION:-2.19.1}" restart: always volumes: - penpot_assets:/opt/data/assets depends_on: penpot-postgres: condition: service_healthy penpot-valkey: condition: service_healthy environment: << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database] PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000 PENPOT_REDIS_URI: redis://penpot-valkey/0 PENPOT_OBJECTS_STORAGE_BACKEND: fs PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets PENPOT_TELEMETRY_ENABLED: "false"The .env file holds the values you must change:
PENPOT_VERSION=2.19.1PENPOT_HOST_PORT=9001PENPOT_PUBLIC_URI=http://localhost:9001PENPOT_SECRET_KEY=CHANGE-MEPOSTGRES_PASSWORD=CHANGE-MEPENPOT_COOKIE_FLAG=disable-secure-session-cookiesGenerate the secret key properly. Penpot derives session and invitation keys from it, so change-this-insecure-key from the sample file is the kind of thing that ends up in a postmortem. The :? guard only stops an empty value, so CHANGE-ME would start fine. Write real values into .env before the first up:
cp .env.example .envsed -i "s|^PENPOT_SECRET_KEY=.*|PENPOT_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(64))')|" .envsed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')|" .envdocker compose up -dFlags That Matter (and One That Bites)
Penpot is configured with PENPOT_FLAGS, a space-separated list of enable-* and disable-* words. Here is what the official file sets and what each one does to you.
disable-email-verification skips the “click this link” step. That is fine when you control who registers, and a bad idea on an open server. The official compose file carries a comment warning you to remove it, together with disable-secure-session-cookies, before exposing Penpot to the internet.
disable-secure-session-cookies exists so you can log in over plain http://localhost. Put Penpot behind HTTPS and delete it. In my kit it comes from PENPOT_COOKIE_FLAG, so production is a one-line change in .env.
enable-prepl-server turns on the admin socket that manage.py talks to. You need it for the user-creation command below.
PENPOT_TELEMETRY_ENABLED is the one that bites. The official file sets it to "true" with the referer compose. Anyone who copies the file without reading the backend environment block runs with telemetry on. The official comment says the data is anonymous. I set it to "false" in the kit anyway. Decide on purpose.
PENPOT_PUBLIC_URI must equal the URL people type in the browser, scheme included. A mismatch gives you broken redirects and links that point at localhost, usually discovered at 2 AM when the first outside user logs in.
Registration: Who Gets an Account
By default, anyone who can reach the server can register. That is the right default for a laptop and the wrong one for a box with a public IP. The configuration docs describe disable-registration for this: it blocks public sign-up, and people with a valid, unexpired team invitation can still register through the invitation link as long as enable-login-with-password is on.
Edit the x-flags anchor in compose.yaml. A PENPOT_FLAGS line in .env does nothing in this kit, because Compose reads the value from the anchor.
x-flags: &penpot-flags PENPOT_FLAGS: >- disable-email-verification enable-prepl-server enable-mcp disable-registration enable-login-with-password ${PENPOT_COOKIE_FLAG:-}For the first account on a closed instance, use the CLI that ships in the backend container:
docker compose exec penpot-backend python3 manage.py create-profileThe docs note that profiles created this way have no verified email. With disable-email-verification set, inviting such a user to a team adds them directly without sending mail, so you do not need SMTP for this flow.
If you want to confirm registration without a browser, the API takes two calls. The first call to prepare-register-profile returns a token, and register-profile redeems it. Penpot also enforces a password policy: my first attempt with an all-lowercase password was rejected with a weak-password error asking for a lowercase letter, an uppercase letter, a digit, and a special character.
U=http://localhost:9001/api/main/methodstok=$(curl -s -X POST $U/prepare-register-profile \ -H 'content-type: application/json' -H 'accept: application/json' \ | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")curl -s -X POST $U/register-profile \ -H 'content-type: application/json' -H 'accept: application/json' \ -d "{\"token\":\"$tok\"}"In my test that returned the new profile, and login-with-password then answered 200 with an auth-token cookie. A wrong password got a 400.
What It Costs in RAM
I brought the kit up on a Linux box with Docker 29 and read docker stats --no-stream after the stack settled, with no users and no open files. This is a one-off idle measurement.
| Container | Idle memory (approx.) |
|---|---|
penpot-backend | 1.9 to 2.0 GiB |
penpot-exporter | 0.3 to 0.7 GiB |
penpot-frontend | 15 to 450 MiB |
penpot-postgres | about 120 MiB |
penpot-admin-console | about 80 to 95 MiB |
penpot-mcp | 60 to 130 MiB |
penpot-valkey | under 15 MiB |
| Total | about 2.6 to 2.8 GiB |
The ranges are wide because I sampled twice, minutes apart, and the exporter and frontend moved a lot between samples. The backend is the anchor: it is a JVM, and it takes under a minute after up before API calls stop returning 502 from the frontend. The frontend answers HTTP 200 immediately, which makes a lazy health check lie to you. Probe an API path such as /api/main/methods/get-profile instead.
Plan for 4 GiB on the host if Penpot is the only tenant, and more once real files with hundreds of boards are open. A 2 GB VPS will not hold this at default settings. The images are not small either: the exporter image alone is 1.73 GB on disk (the biggest of the lot), the backend 796 MB, the frontend 599 MB.
HTTPS and the Reverse Proxy
Penpot’s frontend container speaks plain HTTP on 8080. Put your usual proxy in front of it. Then bind the published port to localhost, so plain HTTP on 9001 is not open to the network: change the frontend ports: entry to "127.0.0.1:${PENPOT_HOST_PORT:-9001}:8080". Docker-published ports bypass host firewalls such as ufw. The Penpot docs include NGINX, Caddy, and Traefik examples, and the nginx one has an extra location for the MCP websocket. A minimal Caddy setup, which I did not test with a real certificate, looks like this:
penpot.example.com { reverse_proxy 127.0.0.1:9001}Then set PENPOT_PUBLIC_URI=https://penpot.example.com, empty out PENPOT_COOKIE_FLAG, and recreate the containers. The upload limit is already generous: the compose file sets PENPOT_HTTP_SERVER_MAX_BODY_SIZE to 367001600 bytes (350 MiB), so check that your proxy does not impose a smaller one.
Storage, Backups, and the Two Things to Back Up
Uploaded assets go to the fs backend by default, which means the penpot_assets volume. The docs also describe an s3 backend (PENPOT_OBJECTS_STORAGE_BACKEND: s3) for AWS or any S3-compatible service. Use it when you run more than one backend replica or you want assets off the box.
Your state is two volumes plus one file: the Postgres volume, the assets volume, and .env (it holds the secret key and the database password). Back the volumes up together, or a restore gives you file records pointing at images that are not there. Compose prefixes volume names with the project name, so the backup borrows the volume from the running backend container instead of guessing its name:
docker compose exec -T penpot-postgres pg_dump -U penpot penpot > penpot-db.sqldocker run --rm --volumes-from "$(docker compose ps -q penpot-backend)" \ -v "$PWD":/out alpine tar czf /out/penpot-assets.tgz -C /opt/data/assets .Penpot also exports single files as .penpot archives, which the docs describe as a ZIP with binary assets plus readable JSON. Treat that as a safety net for the files you care about. The volume backups above are still the real restore path.
Penpot vs Figma: What You Get
Here is where Penpot earns its keep, all checked against the current user guide.
Layout that maps to CSS. Penpot has Flex Layout built on CSS Flexbox and Grid Layout built on CSS Grid. A designer sets up a flex board, and the developer reads the same model in the Inspect tab. That is the best argument for Penpot.
Design tokens in a standard format. Penpot’s tokens follow the W3C Design Tokens Community Group format (still a draft spec), so tools that read DTCG JSON can use them.
Variants and components. Variants group related components such as buttons or toggles into one component with properties like size and state.
Dev handoff. Inspect mode shows measurements and properties, and the layer menu has Copy as CSS.
Plugins. There is a Plugin Manager (Ctrl + Alt + P) and a Penpot Hub to install from. Plugins are installed per user, so each person on a team installs their own.
An open file format. A .penpot export is a ZIP of assets and JSON. If Penpot the project vanished tomorrow, you could still read your files with a script. Try that with a proprietary binary.
An MCP server. The official Compose file includes a penpot-mcp container and the enable-mcp flag. I confirmed the container starts. I did not test what an agent can do with it, so I will not claim more.
Control. Files stay on your disks, backups are pg_dump, upgrades happen when you choose. Figma has no self-hosted edition.
What You Give Up
Figma’s lead is a decade of polish. Penpot’s current release notes read like a product that is moving fast, and 2.19.1 itself shipped four security fixes, including one for editor-role members reading other people’s pending invitation tokens and one for an SVG sanitizer that let script through. Upgrading in small steps matters, and the docs say so: update in small increments and avoid jumping across distant versions. That makes you the person who reads release notes.
The rest of the gap, in order of how much it will annoy you:
- Migration is a conversion job. Penpot cannot open a
.figfile directly. The Penpot team maintains a Figma plugin, Penpot Exporter (v0.26.3 as of November 2026), that converts a Figma file into a.zipyou import into Penpot. Its README lists shapes, frames, components, variants, auto layouts, and color and typography libraries as supported. It also says prototyping settings do not come across and that large files are slow. Run your biggest library through it and compare the result side by side before you plan the switch. - Your team’s habits. Plugins, community files, and tutorials all skew toward Figma. A new hire already knows its shortcuts.
- You are the ops team. Postgres, the JVM, an exporter, and a Valkey queue are four things that can break, and nobody opens a ticket for you.
- Resources. 2.7 GiB idle is the price of a tool most teams open a few hours a day.
I did not quote Figma or Penpot cloud prices here. Figma’s pricing page is rendered in a way that did not give me checkable numbers, and a stale number is worse than none. Check both pricing pages yourself before you do any seat arithmetic.
Who Should Switch
Switch if you run developers and designers in one small team, you already operate a Docker host, and CSS-aligned layout would remove arguments. Switch if data location is a legal or contractual question. Switch if you want the file format and the backups in your own hands.
Stay on Figma if you have a large Figma design system with years of components, a design team that lives in third-party plugins, or nobody who wants to be on call for a JVM. Using a forklift to move a couch is overkill, and so is running your own design server for two freelancers.
The middle path costs nothing: run the kit on a spare box for a week, push one real project through the Penpot Exporter plugin, and see how your designers feel by Friday.
Common Questions
Can Penpot open Figma files?
Not directly. Penpot imports .penpot and .zip files, not .fig. The official Penpot Exporter plugin runs inside Figma and converts a Figma file into a .zip that Penpot can import. Components, variants, auto layouts, and libraries are supported, though some features may not look exactly the same. Prototyping settings do not come across, and very large files export slowly.
How much RAM does self-hosted Penpot need?
Plan for about 4 GiB of host RAM. In one idle test on Penpot 2.19.1, the seven containers used roughly 2.6 to 2.8 GiB together, and the backend took 1.9 to 2.0 GiB of that. Open files and active users push the number up.
Is self-hosted Penpot free?
Yes, the software costs nothing to run yourself, and the official Docker images and Compose file are public. You pay for the server, the storage, and your time handling upgrades, backups, and security patches. Penpot also runs a hosted service at design.penpot.app if you would rather not.
How do I upgrade a self-hosted Penpot instance?
Change PENPOT_VERSION in .env, run docker compose pull, then docker compose up -d. Back up both Penpot volumes first. Move in small version steps and read each release’s upgrade notes: 2.18 and 2.19 add the admin console service, and 2.19.0 has a migration runbook for large instances.