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_configTCP0s240s240s1Alternative 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)"