Self-host Penpot with Docker Compose
Open-source design and prototyping platform, the self-hosted Figma alternative.
penpot
backend
penpotapp/backend:2.16
exporter
penpotapp/exporter:2.16
minio
minio/minio:latest
postgres
postgres:18.3-alpine3.23
redis
redis:7
The steps below take a few minutes end to end.
Requirements
You'll need these installed on the machine you're deploying to:
Docker
Packages Penpot and everything it depends on into an isolated container, so it runs the same way on your machine as it does everywhere else.
Install guideDocker Compose
Reads docker-compose.ymland starts everything in it together. Ships with Docker Desktop. On Linux servers it's usually a separate install.
Install guide1. Docker compose setup and file
Create a folder for Penpot and save the file below into it as docker-compose.yml. It describes every container the stack needs — Penpotitself and any supporting services, such as its database — along with the ports, volumes and environment variables each one uses. You'll also need an empty .env file in the same folder; Docker Compose reads it automatically and uses it to fill in the ${VARIABLE}references you'll see in the file below.
docker-compose.yml
version: "3.9"
services:
backend:
image: penpotapp/backend:2.16
restart: unless-stopped
network_mode: service:penpot
ports:
- 6060:6060
environment:
AWS_ACCESS_KEY_ID: penpot
AWS_SECRET_ACCESS_KEY: ${RANDOM_MINIO_ROOT_PASSWORD}
PENPOT_DATABASE_PASSWORD: ${RANDOM_PG_PASSWORD}
PENPOT_DATABASE_URI: postgresql://localhost/app
PENPOT_DATABASE_USERNAME: app
PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: "367001600"
PENPOT_OBJECTS_STORAGE_BACKEND: s3
PENPOT_OBJECTS_STORAGE_S3_BUCKET: penpot
PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: http://127.0.0.1:9000
PENPOT_OBJECTS_STORAGE_S3_REGION: us-east-1
PENPOT_PUBLIC_URI: http://localhost
PENPOT_REDIS_URI: redis://:${RANDOM_REDIS_PASSWORD}@localhost:6379/0
PENPOT_SECRET_KEY: ${RANDOM_PENPOT_SECRET_KEY}
RANDOM_REDIS_PASSWORD: ${RANDOM_REDIS_PASSWORD}
volumes:
- backend-tmp:/tmp
configs:
- source: backend-bflk-create-minio-bucket.py
target: /scripts/bflk-create-minio-bucket.py
mode: 493
depends_on:
postgres:
condition: service_started
redis:
condition: service_started
command:
- /bin/bash
- -c
- python3 /scripts/bflk-create-minio-bucket.py & exec /bin/bash run.sh
exporter:
image: penpotapp/exporter:2.16
restart: unless-stopped
network_mode: service:penpot
ports:
- 6061:6061
environment:
PENPOT_INTERNAL_URI: http://localhost:8080
PENPOT_PUBLIC_URI: http://localhost
PENPOT_REDIS_URI: redis://:${RANDOM_REDIS_PASSWORD}@localhost:6379/0
PENPOT_SECRET_KEY: ${RANDOM_PENPOT_SECRET_KEY}
RANDOM_REDIS_PASSWORD: ${RANDOM_REDIS_PASSWORD}
volumes:
- exporter-tmp:/tmp
depends_on:
redis:
condition: service_started
minio:
image: minio/minio:latest
restart: unless-stopped
network_mode: service:penpot
ports:
- 9000:9000
environment:
MINIO_ROOT_PASSWORD: ${RANDOM_MINIO_ROOT_PASSWORD}
MINIO_ROOT_USER: penpot
volumes:
- minio-data:/data
depends_on:
backend:
condition: service_started
command:
- server
- /data
penpot:
image: penpotapp/frontend:2.16
restart: unless-stopped
ports:
- 8080:8080
environment:
PENPOT_BACKEND_URI: http://localhost:6060
PENPOT_EXPORTER_URI: http://localhost:6061
PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: "367001600"
PENPOT_PUBLIC_URI: http://localhost
volumes:
- tmp:/tmp
configs:
- source: penpot-bflk-nginx.conf
target: /etc/nginx/bflk-nginx.conf
command:
- nginx
- -c
- /etc/nginx/bflk-nginx.conf
- -g
- daemon off;
healthcheck:
test:
- CMD
- curl
- -f
- http://localhost:8080/readyz
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:18.3-alpine3.23
restart: unless-stopped
network_mode: service:penpot
environment:
POSTGRES_PASSWORD: ${RANDOM_PG_PASSWORD}
POSTGRES_USER: app
redis:
image: redis:7
restart: unless-stopped
network_mode: service:penpot
environment:
REDIS_PASSWORD: ${RANDOM_REDIS_PASSWORD}
entrypoint:
- sh
- -c
command:
- redis-server --requirepass $$REDIS_PASSWORD
volumes:
backend-tmp: null
exporter-tmp: null
minio-data: null
tmp: null
configs:
backend-bflk-create-minio-bucket.py:
content: |
#!/usr/bin/env python3
"""bflk: create the MinIO bucket for Penpot's S3 object storage.
Hand-rolled AWS SigV4 PUT-bucket request (stdlib only) because
neither penpotapp/backend nor minio/minio ships the aws/mc CLI,
and MinIO's own entrypoint unconditionally prepends "minio" to
any command whose first argument isn't literally "minio" - so a
background-script-then-exec pattern (as used for e.g. Seafile)
can't run inside the minio container itself. Runs from here
(the backend container) instead, launched by
deployment.services.backend.command alongside the real
entrypoint.
"""
import hashlib
import hmac
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
ENDPOINT = os.environ.get("PENPOT_OBJECTS_STORAGE_S3_ENDPOINT", "http://127.0.0.1:9000")
BUCKET = os.environ.get("PENPOT_OBJECTS_STORAGE_S3_BUCKET", "penpot")
REGION = os.environ.get("PENPOT_OBJECTS_STORAGE_S3_REGION", "us-east-1")
ACCESS_KEY = os.environ["AWS_ACCESS_KEY_ID"]
SECRET_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
HOST = ENDPOINT.split("://", 1)[-1]
def _sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _signing_key(secret_key, date_stamp, region, service):
k_date = _sign(("AWS4" + secret_key).encode("utf-8"), date_stamp)
k_region = _sign(k_date, region)
k_service = _sign(k_region, service)
return _sign(k_service, "aws4_request")
def put_bucket():
now = datetime.now(timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
payload_hash = hashlib.sha256(b"").hexdigest()
canonical_uri = "/" + BUCKET
canonical_headers = "host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n".format(
HOST, payload_hash, amz_date
)
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_request = "\n".join(
["PUT", canonical_uri, "", canonical_headers, signed_headers, payload_hash]
)
credential_scope = "{}/{}/s3/aws4_request".format(date_stamp, REGION)
string_to_sign = "\n".join(
[
"AWS4-HMAC-SHA256",
amz_date,
credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
]
)
key = _signing_key(SECRET_KEY, date_stamp, REGION, "s3")
signature = hmac.new(key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}".format(
ACCESS_KEY, credential_scope, signed_headers, signature
)
req = urllib.request.Request(
ENDPOINT.rstrip("/") + canonical_uri,
method="PUT",
headers={
"Host": HOST,
"X-Amz-Content-Sha256": payload_hash,
"X-Amz-Date": amz_date,
"Authorization": authorization,
},
data=b"",
)
return urllib.request.urlopen(req, timeout=10)
def main():
for attempt in range(60):
try:
put_bucket()
print("bflk-create-minio-bucket: bucket %r ready" % BUCKET)
return 0
except urllib.error.HTTPError as err:
body = err.read().decode("utf-8", "replace")
if err.code == 409 or "BucketAlreadyOwnedByYou" in body:
print("bflk-create-minio-bucket: bucket %r already exists" % BUCKET)
return 0
print(
"bflk-create-minio-bucket: attempt %d failed: HTTP %s %s"
% (attempt, err.code, body[:300])
)
except Exception as exc:
print("bflk-create-minio-bucket: attempt %d failed: %s" % (attempt, exc))
time.sleep(2)
print("bflk-create-minio-bucket: giving up after retries", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
penpot-bflk-nginx.conf:
content: |
worker_processes auto;
pid /tmp/nginx.pid;
include /etc/nginx/overrides/main.d/*.conf;
events {
worker_connections 65535;
multi_accept on;
}
http {
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_requests 30;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
reset_timedout_connection on;
client_body_timeout 30s;
client_header_timeout 30s;
include /etc/nginx/mime.types;
default_type application/octet-stream;
error_log /dev/stderr;
access_log /dev/stdout;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
send_timeout 300s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_static on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css text/javascript application/javascript application/json application/transit+json image/svg+xml application/wasm;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 32 4k;
map $$http_upgrade $$connection_upgrade {
default upgrade;
'' close;
}
proxy_cache_path /tmp/cache/ levels=2:2 keys_zone=penpot:20m;
proxy_cache_methods GET HEAD;
proxy_cache_valid any 48h;
proxy_cache_key "$$host$$request_uri";
proxy_http_version 1.1;
proxy_set_header Host $$http_host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Scheme $$scheme;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
include /etc/nginx/overrides/http.d/*.conf;
server {
listen 8080 default_server reuseport backlog=16384;
server_name _;
client_max_body_size 367001600;
charset utf-8;
etag off;
proxy_hide_header X-Powered-By;
include /etc/nginx/nginx-security-headers.conf;
root /var/www/app/;
location @handle_redirect {
set $$redirect_uri "$$upstream_http_location";
set $$redirect_host "$$upstream_http_x_host";
set $$redirect_cache_control "$$upstream_http_cache_control";
set $$real_mtype "$$upstream_http_x_mtype";
proxy_buffering off;
proxy_set_header Host "$$redirect_host";
proxy_hide_header etag;
proxy_hide_header x-amz-id-2;
proxy_hide_header x-amz-request-id;
proxy_hide_header x-amz-meta-server-side-encryption;
proxy_hide_header x-amz-server-side-encryption;
proxy_ssl_server_name on;
proxy_pass $$redirect_uri;
include /etc/nginx/nginx-security-headers.conf;
add_header x-internal-redirect "$$redirect_uri";
add_header x-cache-control "$$redirect_cache_control";
add_header cache-control "$$redirect_cache_control";
add_header content-type "$$real_mtype";
}
location /assets {
proxy_pass http://localhost:6060/assets;
recursive_error_pages on;
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirect;
include /etc/nginx/overrides/assets.d/*.conf;
}
location /internal/assets {
internal;
alias /opt/data/assets;
include /etc/nginx/nginx-security-headers.conf;
add_header x-internal-redirect "$$upstream_http_x_accel_redirect";
}
location /api/export {
proxy_pass http://localhost:6061;
}
location /api {
proxy_pass http://localhost:6060/api;
proxy_buffering off;
}
location /plugins {
alias /var/www/app/plugins;
proxy_http_version 1.1;
}
location /readyz {
access_log off;
proxy_pass http://localhost:6060$$request_uri;
}
location /ws/notifications {
proxy_set_header Upgrade $$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://localhost:6060/ws/notifications;
}
include /etc/nginx/overrides/server.d/*.conf;
location / {
include /etc/nginx/overrides/location.d/*.conf;
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$$ {
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "public, max-age=604800" always;
}
location ~ ^/[^/]+/(.*)$$ {
return 301 " /404";
}
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, max-age=0" always;
try_files $$uri /index.html$$is_args$$args /index.html =404;
}
}
}
Volumes
Penpot stores its data in named Docker volumes, so it survives container restarts and updates:
- tmp mounted at /tmp: nginx pid (/tmp/nginx.pid), temp paths (/tmp/*_temp) and proxy cache (/tmp/cache) — all writable under readOnlyRootFilesystem.
2. Secret generation
Penpot needs a few randomly generated secrets — for example, database passwords or an internal session key — before it can start. These are referenced from docker-compose.ymlabove but don't live in it, so they need to end up in your .env file. Pick one of the two options below.
Generating secrets…
3. Start Penpot with Docker Compose
From the folder with docker-compose.yml and .env, run:
Terminal
Start Penpot and all its supporting services in the background.
docker compose up -dThe -d flag runs the stack in the background so it keeps running after you close the terminal. Docker will pull the images the first time, which can take a minute or two.
To check on it afterwards: docker compose ps shows whether containers are healthy, and docker compose logs -f follows their logs if something looks wrong. Once it's running, open http://localhost:8080 in your browser.