Bitflake logo

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.

  1. Docker compose setup and file
    save the generated compose and env files.
  2. Secret generation
    create the random passwords Penpot needs.
  3. Start Penpot with Docker Compose
    bring the stack up and check it's healthy.

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 guide

Docker Compose

Reads docker-compose.ymland starts everything in it together. Ships with Docker Desktop. On Linux servers it's usually a separate install.

Install guide

1. 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 -d

The -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.