Skip to main content

Certification track: Associate Cloud Engineer (ACE)

Penpot Common Shared Configuration Module

The Penpot Common module defines the Penpot design platform configuration for the RAD Modules ecosystem. It is an almost-pure configuration module — its only GCP resource is the auto-generated PENPOT_SECRET_KEY Secret Manager secret; everything else is a config output consumed by platform-specific wrapper modules (Penpot CloudRun and Penpot GKE).

1. Overview​

Purpose: To centralise all Penpot-specific configuration (multi-service architecture, PostgreSQL 15 database setup, environment variable assembly, health probe configuration, GCS asset storage, and WebSocket pub/sub settings) in a single module shared by both Cloud Run and GKE deployments.

Architecture:

Layer 3: Application Wrappers
├── Penpot_CloudRun ──┐
└── Penpot_GKE ──┤── instantiate Penpot_Common
↓
Penpot_Common (this module)
Creates: PENPOT_SECRET_KEY (Secret Manager)
Produces: config, storage_buckets, secret_ids, secret_values, path
↓
Layer 2: Platform Modules
├── App_CloudRun (serverless deployment)
└── App_GKE (Kubernetes deployment)
↓
Layer 1: App_Common (networking, database, storage, secrets, IAM)

Key characteristics:

  • Uses PostgreSQL 15 — unlike Ghost (MySQL 8.0), Penpot relies on PostgreSQL for all deployments. The database type is fixed and cannot be overridden.
  • Creates one Secret Manager secret — an auto-generated PENPOT_SECRET_KEY (shared JWT signing key for the backend and exporter), exposed via the secret_ids / secret_values outputs. No other GCP resources are created.
  • Defines the backend service (Clojure HTTP API + WebSocket server on port 6060). The frontend (nginx React SPA on port 8080) and exporter (headless Chromium for PDF/PNG/SVG export) are assembled by the wrapper modules — see §4.
  • The wrappers default container_protocol to "http1" — WebSockets work over HTTP/1.1 Upgrade; h2c breaks Penpot's HTTP/1.1 nginx frontend (502 "protocol error").
  • Penpot runs its own schema migrations at startup; Penpot Common provides a built-in db-init job that creates the PostgreSQL database and user before first boot.
  • Assembles PENPOT_FLAGS, PENPOT_STORAGE_BACKEND, PENPOT_STORAGE_GCS_BUCKET_NAME, PENPOT_REDIS_URI, JVM_OPTS, and SMTP variables from the module's input variables, producing a consistent environment regardless of whether the wrapper targets Cloud Run or GKE.

2. Outputs​

config​

The application configuration object passed to the platform module via application_config.

FieldValue / Description
app_name"penpot"
application_versionVersion tag (default: "latest")
container_image"penpotapp/backend" — official Penpot backend image from Docker Hub
image_source"prebuilt" — the official Penpot images are used directly; no custom build step
enable_image_mirroringvar.enable_image_mirroring (default true) — mirrors all three images to Artifact Registry
container_port6060 — Penpot backend HTTP API and WebSocket port
database_type"POSTGRES_15" — Penpot requires PostgreSQL 15
db_nameDatabase name (default: "penpot")
db_userDatabase user (default: "penpot")
enable_cloudsql_volumeWhether to mount the Cloud SQL Auth Proxy sidecar (default: true)
cloudsql_volume_mount_path"/cloudsql"
container_resourcesCPU: 2000m, Memory: 2Gi (default) — JVM requires more memory than typical interpreted-language applications
environment_variablesAssembled Penpot environment variables — see §7
additional_services[] — the frontend and exporter are assembled by the wrapper modules, not here (see §4)
startup_probevar.startup_probe (module default: HTTP /api/health) — both wrappers override to TCP; see §5
liveness_probevar.liveness_probe (module default: HTTP /api/health) — overridden per platform (Cloud Run: disabled; GKE: TCP); see §5

storage_buckets​

A list of GCS bucket configurations for provisioning by the platform module:

FieldValue
name_suffix"assets" — the platform module derives the full bucket name (gcs-<service-name>-assets)
locationDeployment region
storage_class"STANDARD"
force_destroytrue
versioning_enabledfalse
lifecycle_rules[]
public_access_prevention"inherited"

secret_ids / secret_values​

secret_ids maps PENPOT_SECRET_KEY to the Secret Manager secret ID (secret-<prefix>-penpot-key) for injection as a secret environment variable; secret_values (sensitive) exposes the generated value itself.

path​

The absolute path to the module directory, used by wrapper modules to locate the scripts/ directory.


3. Input Variables​

Application​

VariableTypeDefaultDescription
application_namestring"penpot"Application name
application_versionstring"latest"Penpot Docker image tag applied to all three service images
descriptionstring"Penpot is an open-source design and prototyping tool"Application description
db_namestring"penpot"PostgreSQL database name
db_userstring"penpot"PostgreSQL application user
cpu_limitstring"2000m"Backend container CPU limit
memory_limitstring"2Gi"Backend container memory limit
min_instance_countnumber1Minimum number of backend instances. Set to 1 or higher — scale-to-zero breaks active WebSocket sessions.
max_instance_countnumber3Maximum number of backend instances
enable_image_mirroringbooltrueMirror all Penpot images to Artifact Registry
enable_cloudsql_volumebooltrueMount Cloud SQL Auth Proxy sidecar socket
environment_variablesmap(string){}Additional environment variables merged into the backend container
initialization_jobslist(any)[]Custom init jobs. Leave empty to use the built-in db-init job (creates the PostgreSQL database and user); Penpot runs its own schema migrations at startup.

Penpot Configuration​

VariableTypeDefaultDescription
penpot_flagsstring"enable-registration enable-login disable-demo-users"Space-separated Penpot feature flags passed as PENPOT_FLAGS
public_uristring""Public URL where users access Penpot. Sets PENPOT_PUBLIC_URI. Auto-detected from the frontend service URL when empty.
jvm_max_heapstring"1g"JVM maximum heap size. Sets -Xmx in JVM_OPTS.
jvm_min_heapstring"512m"JVM initial heap size. Sets -Xms in JVM_OPTS.

Redis​

VariableTypeDefaultDescription
redis_hoststringnullRedis hostname or IP for the WebSocket pub/sub bus. Defaults to NFS server IP when null.
redis_portstring"6379"Redis port
redis_authstring""Redis AUTH password (sensitive)
nfs_server_ipstringnullNFS server IP used as fallback when redis_host is null

SMTP​

VariableTypeDefaultDescription
smtp_enabledboolfalseEnable SMTP. Required for invitations and password resets.
smtp_fromstring""Default sender email address
smtp_reply_tostring""Default reply-to address
smtp_hoststring""SMTP server hostname
smtp_portnumber587SMTP port
smtp_usernamestring""SMTP authentication username
smtp_use_tlsbooltrueEnable STARTTLS
smtp_use_sslboolfalseEnable SSL/TLS

Storage & Volumes​

VariableTypeDefaultDescription
gcs_volumeslist(object)[]GCS Fuse volume mounts (name, bucket_name, mount_path, readonly, mount_options)
startup_probeobjectSee §5Startup probe configuration
liveness_probeobjectSee §5Liveness probe configuration

4. Multi-Service Architecture​

Penpot is a three-tier application. Penpot Common defines only the backend; the wrapper modules assemble the other two tiers. On Cloud Run all three run as one multi-container service — the frontend is the ingress container and the backend and exporter are in-pod sidecars reached over localhost. On GKE the frontend and exporter are deployed as separate additional services alongside the backend.

Backend (penpotapp/backend)​

  • Image: penpotapp/backend:<version>
  • Port: 6060
  • Protocol: HTTP/1.1 (http1) — WebSockets use the HTTP/1.1 Upgrade mechanism
  • Role: Clojure HTTP API server, WebSocket handler for real-time collaboration, database interface, asset management via GCS
  • Environment: Receives all assembled Penpot env vars (see §7)
  • Auth Proxy: Cloud SQL Auth Proxy sidecar for PostgreSQL connectivity
  • Migrations: Runs automatically at startup — no separate init job required

Frontend (penpotapp/frontend)​

  • Image: penpotapp/frontend:<version>
  • Port: 8080 (Penpot's frontend nginx listens on 8080, not 80)
  • Role: nginx server delivering the React SPA to designers' browsers. All design editing happens client-side in the browser; the backend handles persistence and real-time sync.
  • URL: The PENPOT_PUBLIC_URI is set to this service's URL. Users access Penpot through the frontend, whose nginx proxies /api and /ws to the backend. On Cloud Run the frontend is the ingress container, so the frontend URL is the main service URL.

Exporter (penpotapp/exporter)​

  • Image: penpotapp/exporter:<version>
  • Port: 6061
  • Role: Headless Chromium instance that renders Penpot designs and exports them to PDF, PNG, or SVG. Called by the backend when a designer triggers an export operation. Shares the PENPOT_SECRET_KEY JWT secret with the backend.
  • Resources: Headless Chromium is resource-intensive; the exporter service is sized independently of the backend.

5. Health Probes​

Penpot 2.x does not expose an unauthenticated HTTP health endpoint — /api/health returns 404 on the backend (the real readiness path is /readyz). Both wrappers therefore default to TCP probes on the backend port (6060), which pass once the JVM is listening. The module's own startup_probe/liveness_probe variable defaults still carry the HTTP /api/health shape, but every wrapper overrides the probe type to TCP.

Cloud Run Probes (from Penpot_CloudRun variables.tf)​

Cloud Run does not support TCP liveness probes. The startup probe uses TCP to check that the JVM is listening before attempting HTTP health checks.

ProbeTypePort / PathInitial DelayTimeoutPeriodFailure ThresholdPurpose
StartupTCP60605s5s5s40Allows up to 200s total for JVM init + PostgreSQL migration. TCP confirms the port is open.
Liveness—(disabled)————Cloud Run TCP liveness unsupported. Use health_check_config instead.
startup_probe_configTCP—0s240s240s1Alternative probe for LB health checks.
health_check_configHTTP/api/health0s1s10s3HTTP liveness check once the backend is ready.

GKE Probes (from Penpot_GKE variables.tf)​

On GKE both probes are TCP on port 6060 — an HTTP probe against /api/health would 404 and restart-loop a healthy backend.

ProbeTypePortInitial DelayTimeoutPeriodFailure ThresholdPurpose
StartupTCP606030s10s10s30Allows 30s initial delay + 30 × 10s = 330s total for JVM + migrations.
LivenessTCP606060s10s30s3Restarts the pod if Penpot stops listening.

The generous startup probe thresholds accommodate Penpot's migration process on a fresh database, which can be slow on first deployment.


6. Secrets Generated​

Like Django Common (which generates a SECRET_KEY), Penpot Common generates one application-level secret: PENPOT_SECRET_KEY, a 64-character random value stored in Secret Manager as secret-<prefix>-penpot-key. It is the shared JWT signing key used by the backend and the exporter, and is exposed to the wrappers via the secret_ids output (injected as a secret environment variable) and the sensitive secret_values output.

The DB_PASSWORD secret is provisioned automatically by App CloudRun / App GKE and is injected into the backend container as DB_PASSWORD. The backend's entrypoint shell wrapper maps this to PENPOT_DATABASE_PASSWORD at container startup.

If SMTP authentication is required, the SMTP password must be provided via secret_environment_variables in the wrapper module — Penpot Common does not provision it.


7. Environment Variable Assembly​

Penpot Common assembles the following environment variables and passes them through config.environment_variables to the platform module. These are injected into the Penpot backend container at runtime.

Core Penpot Variables​

Environment VariableSourceDescription
PENPOT_FLAGSvar.penpot_flagsSpace-separated feature flags controlling registration, login, and optional features
PENPOT_PUBLIC_URIvar.public_uri (or auto-detected)The URL users use to access Penpot. Used in invitation emails and WebSocket routing. Must match the actual frontend URL.
PENPOT_TELEMETRY_ENABLED"false"Telemetry disabled by default for self-hosted deployments
PENPOT_HTTP_SERVER_HOST"0.0.0.0"Binds to all interfaces — required for Cloud Run and GKE container networking
PENPOT_HTTP_SERVER_PORT"6060"Must match container_port

Database Variables​

Environment VariableSourceDescription
PENPOT_DATABASE_URIBuilt at container start from DB_IP, DB_NAMEThe entrypoint shell wrapper exports postgresql://$DB_IP:5432/$DB_NAME (TCP to the instance private IP)
PENPOT_DATABASE_USERNAMEDB_USERExported by the entrypoint shell wrapper from the platform-injected DB_USER
PENPOT_DATABASE_PASSWORDDB_PASSWORD (from Secret Manager)Exported by the entrypoint shell wrapper from the platform-injected secret

Storage Variables​

Environment VariableSourceDescription
PENPOT_STORAGE_BACKEND"gcs"Instructs the backend to use Google Cloud Storage for asset persistence
PENPOT_STORAGE_GCS_BUCKET_NAMEAuto-set to gcs-<service-name>-assetsThe assets bucket provisioned from Penpot Common's storage_buckets output

Redis Variables​

Environment VariableSourceDescription
PENPOT_REDIS_URIAssembled from redis_host:redis_port (Terraform-computed default), then re-exported by the entrypoint shell wrapperWebSocket pub/sub event bus URI. Format: redis://HOST:PORT/0. The Terraform-side default falls back to nfs_server_ip (or the $(NFS_SERVER_IP) placeholder) when redis_host is empty, primarily so additional services can reference it. At container startup the entrypoint shell wrapper re-exports PENPOT_REDIS_URI from the live NFS_SERVER_IP (falling back to REDIS_HOST) and REDIS_PORT env vars, which takes precedence over the static Terraform value.

JVM Variables​

Environment VariableSourceDescription
JVM_OPTSAssembled from jvm_min_heap, jvm_max_heapSets -Xms (initial heap) and -Xmx (maximum heap). Default: "-Xmx1g -Xms512m".

SMTP Variables (when smtp_enabled = true)​

Environment VariableSourceDescription
PENPOT_SMTP_ENABLED"true" when smtp_enabled = trueEnables outbound email
PENPOT_SMTP_DEFAULT_FROMvar.smtp_fromDefault sender address
PENPOT_SMTP_DEFAULT_REPLY_TOvar.smtp_reply_toDefault reply-to address
PENPOT_SMTP_HOSTvar.smtp_hostSMTP server hostname
PENPOT_SMTP_PORTvar.smtp_portSMTP port (number, converted to string)
PENPOT_SMTP_USERNAMEvar.smtp_usernameSMTP authentication username
PENPOT_SMTP_USE_TLSvar.smtp_use_tlsSTARTTLS flag
PENPOT_SMTP_USE_SSLvar.smtp_use_sslSSL/TLS flag

When smtp_enabled = false, none of the PENPOT_SMTP_* variables are injected, and Penpot operates without outbound email.


8. Platform-Specific Differences​

AspectPenpot CloudRunPenpot GKE
Startup probe typeTCP (port 6060) — Cloud Run does not support TCP liveness, only TCP startupTCP (port 6060) — /api/health 404s on the backend, so HTTP probes are not used
Liveness probeDisabled — Cloud Run does not support TCP liveness probes; health_check_config is used insteadTCP (port 6060) — 60s initial delay, 30s period
min_instance_count0 (default, user-configurable) — set to 1 or more to keep active WebSocket sessions warm1 (default, user-configurable) — no scale-to-zero in production
container_protocol"http1" — WebSockets work over HTTP/1.1 Upgrade; h2c breaks the HTTP/1.1 nginx frontend (502 "protocol error")"http1" (default) — standard HTTP/1.1 to the pods
session_affinityNot applicable in Cloud Run (managed load balancing)"ClientIP" default — important for Penpot WebSocket stability; routes repeat clients to the same pod
PENPOT_PUBLIC_URISet to the predicted frontend Cloud Run service URL (using the deployment ID and project naming convention)Must be set explicitly via environment_variables for GKE — no equivalent auto-detection
DB_HOSTCloud SQL Auth Proxy socket path (/cloudsql/...)Cloud SQL private IP address
enable_nfstrue (default) — NFS server also serves as the fallback Redis hosttrue (default) — same NFS fallback Redis pattern
Image source"prebuilt" — official Penpot Docker Hub images, mirrored to Artifact Registry"prebuilt" — same
Additional servicesNone — single multi-container service: frontend is the ingress container, backend and exporter are in-pod sidecars on localhostFrontend and exporter as GKE Deployment additional services

9. Penpot Feature Flags Reference​

The penpot_flags variable accepts a space-separated list of flags. These are passed directly to the backend as the PENPOT_FLAGS environment variable. The most commonly used flags:

FlagEffect
enable-registrationAllow new users to self-register. Appropriate for open or internal team deployments.
disable-registrationBlock self-registration. Admins must invite users by email. Use for closed-team or client deployments.
enable-login-with-passwordAllow login with email and password (default authentication method).
enable-oidc-googleEnable Google OAuth OIDC SSO login. Requires OIDC client credentials to be configured.
enable-oidc-githubEnable GitHub OAuth OIDC SSO login.
disable-demo-usersPrevent creation of demo/guest accounts. Recommended for production.
enable-webhooksEnable webhook callbacks for design events.
enable-email-verificationRequire new users to verify their email address before accessing the platform.

Flags are additive and space-separated. The default value "enable-registration enable-login disable-demo-users" is appropriate for initial setup. For a closed-team deployment:

penpot_flags = "disable-registration enable-login-with-password disable-demo-users enable-email-verification"

For an SSO-only deployment (no password login):

penpot_flags = "disable-registration enable-oidc-google disable-demo-users"

10. Implementation Pattern​

The following shows how Penpot_CloudRun instantiates Penpot_Common and passes its outputs to App_CloudRun:

# Penpot_CloudRun calls Penpot_Common for application config
module "penpot_app" {
source = "../Penpot_Common"

application_version = var.application_version
db_name = var.db_name
db_user = var.db_user
cpu_limit = var.cpu_limit
memory_limit = var.memory_limit
penpot_flags = var.penpot_flags
jvm_max_heap = var.jvm_max_heap
jvm_min_heap = var.jvm_min_heap
smtp_enabled = var.smtp_enabled
smtp_host = var.smtp_host
smtp_port = var.smtp_port
smtp_from = var.smtp_from
smtp_reply_to = var.smtp_reply_to
smtp_username = var.smtp_username
smtp_use_tls = var.smtp_use_tls
smtp_use_ssl = var.smtp_use_ssl
enable_cloudsql_volume = var.enable_cloudsql_volume
enable_image_mirroring = var.enable_image_mirroring
startup_probe = var.startup_probe
liveness_probe = var.liveness_probe
}

# Assemble the four locals the Foundation Module consumes
locals {
application_modules = { penpot = module.penpot_app.config }
module_env_vars = { REDIS_HOST = var.redis_host }
module_secret_env_vars = module.penpot_app.secret_ids # PENPOT_SECRET_KEY
module_storage_buckets = module.penpot_app.storage_buckets
scripts_dir = abspath("${module.penpot_app.path}/scripts")
}

# Pass assembled config to App_CloudRun
module "app_cloudrun" {
source = "../App_CloudRun"

application_modules = local.application_modules
module_storage_buckets = local.module_storage_buckets
scripts_dir = local.scripts_dir
# ... all other variables passed through
}

11. Exploring with the GCP Console​

Penpot Common creates only the PENPOT_SECRET_KEY secret directly. After deployment, the other resources it defines (the gcs-<service-name>-assets GCS bucket, the backend environment variables) are visible through the wrapper module's infrastructure.

Verifying the assets bucket:

Navigate to Cloud Storage → Buckets and search for assets (the bucket is named gcs-<service-name>-assets). Confirm:

  • Bucket exists in the expected region.
  • Access control is uniform bucket-level access (public_access_prevention = "inherited").
  • The Cloud Run SA or GKE Workload Identity SA has roles/storage.objectAdmin in the bucket's IAM policy.

Verifying the backend environment:

Navigate to Cloud Run (or GKE → Workloads for the GKE variant), select the Penpot backend service, and click Edit & Deploy New Revision (you do not need to save). In the Container tab, scroll to Variables & Secrets to confirm:

  • PENPOT_FLAGS is set correctly.
  • PENPOT_STORAGE_BACKEND is gcs.
  • PENPOT_STORAGE_GCS_BUCKET_NAME points to the correct bucket.
  • PENPOT_REDIS_URI points to the expected Redis host.
  • JVM_OPTS contains the expected -Xmx and -Xms values.
  • PENPOT_SMTP_ENABLED is true if SMTP was configured.

Checking the health endpoint:

Once the backend is deployed, its /readyz endpoint returns HTTP 200 when the application is ready (note: /api/health returns 404 — Penpot 2.x has no such path). From Cloud Shell or a machine with access to the service URL:

curl -o /dev/null -s -w "%{http_code}\n" https://BACKEND_SERVICE_URL/readyz

A response of 200 confirms the backend has completed PostgreSQL migrations and is ready to serve requests.


12. Exploring with gcloud​

The following commands help verify what Penpot Common has assembled and confirm the assets bucket is correctly configured.

Verify the assets bucket exists and has the correct region:

gcloud storage buckets describe gs://gcs-SERVICE_NAME-assets \
--format="table(name,location,storageClass,iamConfiguration.publicAccessPrevention)"

Check the IAM policy on the assets bucket:

gcloud storage buckets get-iam-policy gs://gcs-SERVICE_NAME-assets \
--format="table(bindings.role,bindings.members)"

Verify the backend Cloud Run service environment (check PENPOT_FLAGS, JVM_OPTS, PENPOT_STORAGE_BACKEND):

gcloud run services describe SERVICE_NAME \
--project=PROJECT_ID \
--region=REGION \
--format="yaml(spec.template.spec.containers[0].env)"

Check that PENPOT_REDIS_URI is set correctly in the running revision:

gcloud run revisions describe REVISION_NAME \
--project=PROJECT_ID \
--region=REGION \
--format="json" | \
python3 -c "import sys,json; [print(e['name'],'=',e.get('value','[secret]')) for e in json.load(sys.stdin)['spec']['containers'][0]['env'] if 'PENPOT' in e['name'] or 'JVM' in e['name']]"

List all objects in the assets bucket (design thumbnails and uploaded files):

gcloud storage ls gs://gcs-SERVICE_NAME-assets --recursive | head -50

Check the size of the assets bucket (useful for estimating growth):

gcloud storage du gs://gcs-SERVICE_NAME-assets --summarize

Confirm the Cloud SQL PostgreSQL 15 instance is running:

gcloud sql instances list \
--project=PROJECT_ID \
--filter="databaseVersion:POSTGRES_15" \
--format="table(name,state,databaseVersion,region,settings.dataDiskSizeGb)"

Verify the penpot database and user exist:

gcloud sql databases list \
--instance=INSTANCE_NAME \
--project=PROJECT_ID \
--format="table(name,charset,collation)"

gcloud sql users list \
--instance=INSTANCE_NAME \
--project=PROJECT_ID \
--format="table(name,host,type)"

Test the /readyz endpoint from Cloud Shell:

# Replace with the actual backend Cloud Run service URL
curl -s -o /dev/null -w "%{http_code}\n" https://BACKEND_URL/readyz

Check Cloud Logging for Penpot backend startup and migration output:

gcloud logging read \
'resource.type="cloud_run_revision" AND resource.labels.service_name="penpot-backend" AND textPayload:"migration"' \
--project=PROJECT_ID \
--limit=20 \
--format="table(timestamp,textPayload)"

Need RAD to do something it does not do yet? Request it on the roadmap, or vote on what is already there.