Design teams often start with cloud design tools because they are easy to share, but regulated projects, internal product teams, and agencies with strict client boundaries sometimes need a collaborative design workspace that stays under their own operational controls. Penpot is a strong fit for that use case: it is open source, works in the browser, supports design and prototyping workflows, and can be operated on a standard Linux host with PostgreSQL and Redis.
This production-oriented guide deploys Penpot on Ubuntu with Docker Compose and Caddy. The goal is not just to make the login page appear; it is to create a maintainable service with clear secrets, HTTPS termination, predictable backups, health checks, and troubleshooting steps that an operations team can repeat during upgrades or incidents.
Architecture and flow overview
The deployment uses Caddy as the public TLS endpoint, a Docker Compose project for Penpot services, PostgreSQL for persistent application data, Redis for queue/cache functions, and the Penpot exporter service for rendering exports and thumbnails. Caddy listens on ports 80 and 443, obtains certificates automatically, and proxies requests to a loopback-only port published by the Penpot frontend container.
Traffic flow is intentionally simple: users connect to https://penpot.example.com, Caddy handles HTTPS, requests are forwarded to 127.0.0.1:9001, and containers communicate over a private Docker network. PostgreSQL and Redis are never exposed to the public internet. Persistent state lives under /opt/penpot, which makes backup and restore planning straightforward.
Prerequisites
- Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 4 GB RAM, and 30 GB free disk for small teams.
- A DNS record such as
penpot.example.compointing to the server public IP. - Root or sudo access, outbound internet access, and a firewall that allows HTTP and HTTPS.
- An SMTP relay for invitations and password emails. Penpot can boot without email, but production onboarding is much smoother with SMTP configured.
Step-by-step deployment
1) Install Docker, Compose, Caddy, and firewall basics
Start with system packages, Docker from Ubuntu repositories, the Compose plugin, Caddy, and UFW. If your organization uses Docker's upstream repository, use that standard instead; keep the rest of the layout the same.
sudo apt update
sudo apt install -y docker.io docker-compose-v2 caddy ufw openssl
sudo systemctl enable --now docker caddy
sudo usermod -aG docker "$USER"
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
If the copy button is unavailable in your browser, select the command block and copy it manually.
2) Create the application layout and strong secrets
Keep configuration, database files, Redis data, and uploaded assets in one service directory. Generate the database password and Penpot secret before writing the environment file, then store a root-readable copy for future recovery procedures.
sudo mkdir -p /opt/penpot/{postgres,redis,assets,backups}
cd /opt/penpot
DB_PASSWORD=$(openssl rand -base64 36 | tr -d '\n')
PENPOT_SECRET=$(openssl rand -base64 64 | tr -d '\n')
sudo install -m 600 /dev/null /opt/penpot/.env
sudo tee /opt/penpot/.env >/dev/null <
If the copy button is unavailable in your browser, select the command block and copy it manually.
3) Define the Docker Compose stack
This stack publishes only the frontend to loopback. Backend, exporter, PostgreSQL, and Redis stay on the private Docker network. Pin image versions in environments with strict change-control; latest is convenient for a lab but less predictable for production.
sudo tee /opt/penpot/docker-compose.yml >/dev/null <<'EOF'
services:
penpot-frontend:
image: penpotapp/frontend:latest
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:9001:80"
depends_on:
- penpot-backend
- penpot-exporter
penpot-backend:
image: penpotapp/backend:latest
restart: unless-stopped
env_file: .env
environment:
PENPOT_FLAGS: enable-registration enable-login-with-password disable-email-verification
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
PENPOT_REDIS_URI: redis://penpot-redis/0
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
PENPOT_SMTP_DEFAULT_FROM: ${SMTP_FROM}
PENPOT_SMTP_DEFAULT_REPLY_TO: ${SMTP_FROM}
PENPOT_SMTP_HOST: ${SMTP_HOST}
PENPOT_SMTP_PORT: ${SMTP_PORT}
PENPOT_SMTP_USERNAME: ${SMTP_USER}
PENPOT_SMTP_PASSWORD: ${SMTP_PASSWORD}
PENPOT_SMTP_TLS: "true"
volumes:
- ./assets:/opt/data/assets
depends_on:
- penpot-postgres
- penpot-redis
penpot-exporter:
image: penpotapp/exporter:latest
restart: unless-stopped
environment:
PENPOT_PUBLIC_URI: ${PENPOT_PUBLIC_URI}
PENPOT_REDIS_URI: redis://penpot-redis/0
depends_on:
- penpot-redis
penpot-postgres:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: penpot
POSTGRES_USER: penpot
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
penpot-redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- ./redis:/data
EOF
If the copy button is unavailable in your browser, select the command block and copy it manually.
4) Configure Caddy for HTTPS
Because Compose publishes the frontend on 127.0.0.1:9001, the host-level Caddy service can reach Penpot without exposing the container port externally. Replace the domain and email with your real values before reloading Caddy.
sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
{
email [email protected]
}
penpot.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:9001
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
If the copy button is unavailable in your browser, select the command block and copy it manually.
5) Start Penpot and watch first boot
Start the project from the service directory and watch the backend logs during the first migration. Initial boot can take a few minutes while images download and database migrations complete.
cd /opt/penpot
sudo docker compose --env-file .env up -d
sudo docker compose ps
sudo docker compose logs -f --tail=100 penpot-backend
If the copy button is unavailable in your browser, select the command block and copy it manually.
Configuration and secrets handling best practices
Treat /opt/penpot/.env as sensitive infrastructure material. It contains the database password, SMTP password, and application secret key. Do not paste it into tickets, chat tools, screenshots, or unencrypted documentation. If the secret key changes after users have sessions and encrypted data, plan a maintenance window and test the impact in staging first.
For production, disable open registration after the initial admin account is created unless the workspace is intentionally public. Move from disable-email-verification to verified email once SMTP is proven. For larger teams, place PostgreSQL on managed infrastructure or at least add disk monitoring, WAL-aware backups, and restore drills. Also pin container versions and schedule upgrades during a maintenance window rather than pulling new images automatically.
Backups and recovery routine
A usable backup includes PostgreSQL data, uploaded assets, the Compose file, and the environment file. Store backups outside the application server and test restore on a separate host. A backup that has never been restored is only a hopeful archive.
sudo tee /usr/local/sbin/backup-penpot >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F-%H%M%S)
cd /opt/penpot
mkdir -p backups
sudo docker compose exec -T penpot-postgres pg_dump -U penpot penpot | gzip > "backups/penpot-db-${STAMP}.sql.gz"
tar -czf "backups/penpot-assets-config-${STAMP}.tar.gz" assets docker-compose.yml .env
find backups -type f -mtime +14 -delete
EOF
sudo chmod 750 /usr/local/sbin/backup-penpot
sudo /usr/local/sbin/backup-penpot
ls -lh /opt/penpot/backups
If the copy button is unavailable in your browser, select the command block and copy it manually.
Verification checklist
After deployment, verify each layer separately instead of relying on a browser success page alone. Confirm DNS resolves correctly, Caddy has a certificate, the loopback proxy returns HTTP, containers are healthy, and the application can send email. Create a test workspace, upload a small asset, invite a user, and export a simple design to exercise the backend, assets directory, SMTP, and exporter service.
dig +short penpot.example.com
curl -I https://penpot.example.com
curl -I http://127.0.0.1:9001
cd /opt/penpot && sudo docker compose ps
sudo docker compose logs --tail=80 penpot-frontend penpot-backend penpot-exporter
If the copy button is unavailable in your browser, select the command block and copy it manually.
Common issues and fixes
Caddy returns 502 Bad Gateway
Check that Compose publishes 127.0.0.1:9001:80 and that the Caddyfile points to 127.0.0.1:9001. If you used expose only, a host-level Caddy service cannot reach the container through loopback.
Registration works but email never arrives
Verify SMTP host, port, username, password, TLS mode, and sender address. Many providers require an app password or verified sender domain. Review backend logs immediately after sending a test invitation.
Exports or thumbnails fail
Inspect the exporter container logs and confirm it can reach Redis. Restart the exporter after configuration changes, then test with a simple frame export before declaring the service healthy.
Uploads disappear after container recreation
Confirm the backend uses PENPOT_ASSETS_STORAGE_BACKEND=assets-fs and that ./assets is mounted to /opt/data/assets. Back up that directory with the database.
FAQ
Can Penpot run on a single small VPS?
Yes, small teams can start on a 2 vCPU and 4 GB RAM server. Increase RAM and disk as files, exports, and concurrent users grow.
Should PostgreSQL and Redis be exposed to the internet?
No. Keep them on the private Docker network. Only Caddy should receive public traffic, and only the frontend should be published to loopback.
Can I use NGINX or Traefik instead of Caddy?
Yes. The same Compose project works behind another reverse proxy, but update the proxy target to the loopback port and keep HTTPS headers consistent.
How do I upgrade Penpot safely?
Take a database and assets backup, record current image tags, pull new images in a maintenance window, and watch backend migration logs before reopening access.
Do I need SMTP on day one?
You can test without it, but production teams should configure SMTP so invitations, password resets, and account workflows are reliable.
Where should backups be stored?
Keep local short-term backups for quick restores, then copy them to encrypted off-server storage such as S3-compatible object storage or a managed backup platform.
Internal links
- Deploy OpenProject with Docker Compose, Caddy, PostgreSQL, and Redis
- Deploy Listmonk with Docker Compose, Caddy, and PostgreSQL
- Deploy Vikunja with Docker Compose, Caddy, and PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.