Configuration reference

Variables

Edit a highlighted value in any command, or fill it in here: every command on this page follows, and Copy copies a command with your values. They stay in this browser.

  • YOUR_SA_EMAIL

    The e-mail of gcportrait’s backend service account; the Permissions page shows it. Example: gcportrait-backend@demo-gcportrait.iam.gserviceaccount.com

gcportrait renders Google Cloud-style architecture diagrams. It is an independent open-source project and is not affiliated with, endorsed by, or sponsored by Google. Google Cloud and the Google Cloud product icons are trademarks of Google and are used under Google's icon usage terms.

Every setting of a gcportrait instance, its default and who sets it. self-host.md is the guide and self-host-options.md explains each option with an example; this page is the reference. Nothing configured here is a secret, and no credential is ever configured through the environment: the backend uses the caller's own token, or in service-account mode its own Application Default Credentials (on Cloud Run: the service's identity). A test keeps the tables below equal to the backend's schema, the scripts' --help and the build's variables (node scripts/user-docs.mjs).

Section
Backend environment The variables the service reads at start-up (backend/src/config.ts).
Service-account credentials What Application Default Credentials read, in service-account mode only.
Environment on Cloud Run What infra/deploy.sh sets them to.
infra/install.sh The one-line installer's own flags; it passes every other one to deploy.sh.
infra/deploy.sh Its flags and the Cloud Run settings it applies.
infra/uninstall.sh Its flags.
Names and labels The resources deploy.sh creates, and how uninstall.sh recognises them.
The image Build arguments, environment, files, user, port, health.
Local runs with compose infra/compose.yaml's variables.
Cloud Run Button What app.json asks for and what its hook sets.
Release workflow What release.yml publishes; it needs no secret and no variable.
SPA build Build-time variables of the frontend.

Backend environment

Parsed once at start-up with zod (backend/src/config.ts; backend/.env.example documents each for local development). Blank counts as unset. An invalid value stops start-up with a message that names the variable, never its value.

Variable Default Meaning
PORT 8080 TCP port to listen on. Cloud Run sets it (8080).
HOST 0.0.0.0 Interface to bind. pnpm dev uses localhost.
LOG_LEVEL info pino level: fatal, error, warn, info, debug, trace, silent.
LOG_FORMAT json json (one object per line with severity and message, for Cloud Logging) or pretty (development only; needs the pino-pretty dev dependency, which the image does not have).
OAUTH_CLIENT_ID unset Public OAuth web client id for "Sign in with Google". Unset: sign-in shows as not configured; demo mode and export files still work. Letters, digits, dots, dashes, underscores. self-host-options.md, "The OAuth client in detail".
SA_MODE_ENABLED false Exactly true enables service-account mode; any other value leaves it off (a value other than true/false is a start-up warning). With it on, start-up resolves the instance's Application Default Credentials and stops if they are not usable.
SA_MODE_STRICT_EXPOSURE false true adds the exposure check to the service-account gate: a scope whose details the account can read itself is refused (backend/README.md, "Service-account mode"). Any value other than true, false or unset stops start-up, so a typo never leaves the check off unnoticed.
BACKEND_SA_EMAIL unset The account customers grant roles/cloudasset.viewer to, shown on the Permissions page (backendServiceAccount in /api/config). With service-account mode on it must be the account the credentials resolve to, which fills it in when unset.
HOSTED_BY unset Who runs the instance, shown before sign-in as "Hosted by NAME." (hostedBy in /api/config, null when unset). Plain text on one line, at most 80 characters, without < or >, never naming Google (ADR 0018).
STATIC_DIR ../frontend/dist from the backend package The built SPA; served when it contains index.html. The image sets /app/frontend/dist.
TRUST_PROXY_HOPS 0 Reverse-proxy hops whose X-Forwarded-For is trusted for the client address (rate limiting). 1 behind Cloud Run's front end; 0 wherever clients can reach the process directly.
APP_VERSION version of backend/package.json Reported by /healthz and /api/config; at most 64 characters. The image sets it at build time.
SOURCE_URL https://github.com/florianchrometz/gcportrait Where the source of the running version is published, an http(s) URL; the app's footer links to it as "Source (MIT)", a courtesy to the people who use the instance (the MIT License does not ask for it). If you run a modified version and publish its source, you can point it there.
DEMO_ONLY false true: a sign-in-free demo (self-host-options.md, "Demo-only instance"): /api/config reports demoOnly: true, the four routes that call Google are not registered (JSON 404), the CSP allows no Google host, the page is cross-origin isolated (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp; security.md), and the Connect page offers the demo organisations only. With it, OAUTH_CLIENT_ID and SA_MODE_ENABLED=true stop start-up. Any value other than true, false or unset stops start-up.
LEGAL_DIR unset A directory holding impressum.md and/or privacy.md (UTF-8 Markdown, at most 64 KiB each, no control characters), served at /api/legal and shown at /impressum and /privacy, linked from the footer (self-host-options.md, "Legal pages: link or serve"). Relative to the working directory. Read once at start-up; a missing directory, neither file, or an unusable text stops start-up.
LEGAL_BUNDLE unset The same texts as one JSON object of base64 strings, {"impressum":"…","privacy":"…"} (either key may be left out); deploy.sh --legal-dir writes it. Set this or LEGAL_DIR, not both.
LEGAL_IMPRESSUM_URL unset The address of an Impressum you publish elsewhere, such as on your own website, instead of serving its text: an https:// URL without a user name or password, at most 2048 characters. Reported in /api/config as legal.impressumUrl (null when unset); the app's footer links it, in the same tab and marked as a link to another site. Set together with an impressum.md in LEGAL_DIR or LEGAL_BUNDLE, it stops start-up: a page is linked or served, not both.
LEGAL_PRIVACY_URL unset The same for the privacy notice: legal.privacyUrl in /api/config; not together with a privacy.md in LEGAL_DIR or LEGAL_BUNDLE.
RENDER_API true POST /api/render, SVG and PNG drawn on the server (api.md). false leaves the route out (JSON 404). A demo-only instance never has it, and true with DEMO_ONLY=true stops start-up. Any value other than true, false or unset stops start-up.
RENDER_MAX_ASSETS 60000 Assets per render, 1 to 1 000 000, for every source (demo XL, 51 242 assets, fits). More: 422 TOO_MANY_ASSETS; a pull stops as soon as it passes the limit.
RENDER_MAX_PIXELS 67108864 Pixels per PNG, 1 000 000 to 134 217 728 (2^27, the app's export limit). A larger PNG is drawn at the largest two-decimal scale that fits, named in x-gcportrait-scale. The rasteriser takes about 10 bytes per pixel while it draws: the default fits 2 GiB, 2^27 wants --memory 4Gi.
RENDER_TIMEOUT_MS 300000 From the start of a render (its pull or upload included) to the first byte of the answer, 10 000 to 3 600 000 ms. Then 503 RENDER_TIMEOUT. Keep it below the platform's request timeout (Cloud Run: 900 s; deploy.sh refuses more than 840 000).
RENDER_MAX_BODY_BYTES 26214400 The uploaded file of a render, 1 MiB to 1 GiB. More: 413 BODY_TOO_LARGE. Cloud Run takes at most 32 MiB per HTTP/1 request, and every upload must arrive within the 30 s body deadline: read larger exports with a scope source instead.

The backend's fixed limits (rate limits per client address and per token hash, four concurrent streams per token, 60 s and 25 MiB per upstream page, 1 GiB per export read; for renders one at a time per instance, ten a minute per token, a scale of at most 3 and 16 384 px a side, and a 1 GiB heap for the render worker) are not configurable; backend/README.md and api.md list them.

Memory. A full instance needs about 300 MiB with its page budget full, plus, while a render runs, the render worker's heap (at most 1 GiB) and about 10 bytes per PNG pixel up to RENDER_MAX_PIXELS. With the defaults that fits 2 GiB, deploy.sh's default; on less, set RENDER_API=false or lower RENDER_MAX_PIXELS. A render keeps the instance's one CPU busy for its duration (a few seconds for demo XL's overview), so other requests on that instance wait meanwhile; further renders get 429 RENDER_BUSY until Cloud Run adds an instance (up to --max-instances).

Locally. pnpm dev starts the backend with HOST=localhost and LOG_FORMAT=pretty and reads backend/.env when it exists (backend/.env.example documents each variable; none is needed for the demo). After pnpm build, pnpm --filter @gcportrait/backend start runs the bundle the image runs, and reads the environment only, not backend/.env.

Service-account credentials

With SA_MODE_ENABLED=true the backend resolves its own identity at start-up, without a client library (backend/src/auth/identity.ts), and stops with a message when it finds none it can use. These variables are not part of the schema above; they are read only for this:

Variable Effect
GOOGLE_APPLICATION_CREDENTIALS a credentials file to use: an impersonated_service_account file (gcloud auth application-default login --impersonate-service-account=YOUR_SA_EMAIL) or a service-account key file. A person's own authorized_user credentials are refused.
CLOUDSDK_CONFIG without the variable above: gcloud's configuration directory, where the application-default credentials file is looked for (default ~/.config/gcloud)
APPDATA the same on Windows (%APPDATA%\gcloud)

Without a file, the metadata server answers: on Cloud Run, the service's own account. permissions.md, "For the instance's operator" explains the mode.

Environment on Cloud Run

infra/deploy.sh writes the service's whole environment on every run (--env-vars-file), so a variable set by hand in the console is replaced at the next deploy; give it to deploy.sh as a flag instead.

Variable Set to
TRUST_PROXY_HOPS 1 (requests always arrive through Cloud Run's front end)
LOG_LEVEL --log-level, default info
SA_MODE_ENABLED true with --sa-mode, else false
SA_MODE_STRICT_EXPOSURE true with --sa-strict-exposure, else unset
BACKEND_SA_EMAIL the backend account, SERVICE-backend@PROJECT.iam.gserviceaccount.com; unset with --demo-only
DEMO_ONLY true with --demo-only, else unset
LEGAL_BUNDLE built from --legal-dir (the texts' base64), else unset
LEGAL_IMPRESSUM_URL, LEGAL_PRIVACY_URL --impressum-url, --privacy-url, else unset
OAUTH_CLIENT_ID --oauth-client-id, else unset
HOSTED_BY --hosted-by, else unset
SOURCE_URL --source-url, else unset (the default above)
RENDER_API false with --no-render-api, else unset (on; off with --demo-only)
RENDER_MAX_ASSETS, RENDER_MAX_PIXELS, RENDER_TIMEOUT_MS, RENDER_MAX_BODY_BYTES --render-env NAME=VALUE, else unset (the defaults above)
PORT 8080, by Cloud Run
STATIC_DIR, APP_VERSION, NODE_ENV from the image

infra/install.sh

curl -fsSL https://gcportrait.app/install.sh | bash -s -- --project PROJECT_ID [flags], or bash install.sh --project PROJECT_ID [flags] with the file downloaded first. It downloads the deploy scripts of a release (gcportrait-deploy-vX.Y.Z.tar.gz), checks their SHA-256 against the release's checksums.txt before it unpacks or runs anything, and runs their infra/deploy.sh --image vX.Y.Z with every flag below it does not know (self-host-options.md, "The installer"). Exit status: deploy.sh's, or 1 when the installer refuses (a missing tool, a bad flag, --build, a download or a checksum that fails).

Flag Default Effect
--version TAG the newest release The release to deploy, such as v1.2.3 (1.2.3 works too): its deploy scripts, and --image TAG for deploy.sh. The newest is GitHub's "latest" release, never a pre-release, found with the GitHub API or, when the API refuses, the redirect of github.com/florianchrometz/gcportrait/releases/latest.
--dir PATH gcportrait-TAG in the current directory Where the scripts are kept: a new or empty directory, or one that holds the same release already. Without it, gcportrait-TAG-2 and so on when gcportrait-TAG holds other files.
-h, --help Usage.

Needs curl, tar, sha256sum or shasum, and the Google Cloud CLI, each checked first. --image given to the installer goes to deploy.sh in place of the release's own image; --build is refused (it needs a clone).

infra/deploy.sh

infra/deploy.sh --project PROJECT_ID [flags]: --project is the only flag it needs. Declarative: each run sets the service to what its flags say, and a flag left out gets its default. A run ends with the next steps (open the address; create the OAuth client, with the console link and the origins; redeploy with --oauth-client-id, the exact command), then the instance's settings and a command that repeats the current setup; after --build, that command deploys the image just built with --image instead of building again. The commands name the script by its path from the current directory (gcportrait-vX.Y.Z/infra/deploy.sh where the installer kept it).

Flag Default Effect
--project ID required The project to deploy into; billing must be enabled.
--region REGION europe-west3 Region of the service and, with --build, of the build, repository and bucket.
--service NAME gcportrait Cloud Run service name, at most 22 characters (lowercase letters, digits, dashes). The other names derive from it (below), so several instances can share a project.
--image TAG|IMAGE latest of the published image A tag deploys ghcr.io/florianchrometz/gcportrait:TAG (PUBLISHED_IMAGE in infra/common.sh), pulled through the Artifact Registry remote repository ghcr in the region, which deploy.sh creates once per project and region (REGION-docker.pkg.dev/PROJECT/ghcr/florianchrometz/gcportrait:TAG). A release's git tag works as well: v1.2.3 deploys the image tag 1.2.3 and v1.2 the tag 1.2, as release.yml publishes them; dev is the maintainer's pre-release build. ghcr.io/OWNER/NAME[:TAG|@sha256:…] pulls any public ghcr.io image the same way. Any other value with a / is used as a full image reference (tag or digest). A ghcr.io image is looked up on ghcr.io (with curl) before anything is changed; an Artifact Registry image is looked up with gcloud before anything is deployed (after the APIs are enabled and the backend's account is created). Excludes --build.
--build off Builds the checkout around the script with Cloud Build in the project and deploys that image. Needs a clone of the repository (the release's scripts from the installer refuse it) and curl (the build bucket is created through the Cloud Storage API).
--build-machine TYPE e2-highcpu-8 Cloud Build machine type for --build.
--oauth-client-id ID unset OAUTH_CLIENT_ID.
--hosted-by TEXT unset HOSTED_BY; checked against the backend's rules before deploying.
--iap off Identity-Aware Proxy in front of the service instead of public access; a redeploy without it makes the service public again. Needs curl. User mode behind it is designed to work: the app sends the person's token in X-Gcportrait-Token (ADR 0031, proposed; self-host-options.md); try it with --dry-run first.
--iap-members LIST your gcloud account Comma-separated user:, group:, serviceAccount: or domain: principals granted roles/iap.httpsResourceAccessor; repeatable; added, never removed. Needs --iap.
--sa-mode off SA_MODE_ENABLED=true.
--sa-strict-exposure off SA_MODE_STRICT_EXPOSURE=true; needs --sa-mode.
--log-level LEVEL info LOG_LEVEL.
--source-url URL unset SOURCE_URL.
--max-instances N 10 (2 with --demo-only) Upper bound of Cloud Run instances (1 to 999).
--memory SIZE 2Gi (256Mi with --demo-only) Memory of each instance, 512Mi to 4Gi (Cloud Run's limit with one CPU). The render API's defaults fit 2 GiB; RENDER_MAX_PIXELS=134217728 wants 4Gi (Memory).
--no-render-api off RENDER_API=false: no POST /api/render. Excludes --render-env and --demo-only (which has no render API anyway).
--render-env NAME=VALUE unset One of RENDER_MAX_ASSETS, RENDER_MAX_PIXELS, RENDER_TIMEOUT_MS (at most 840 000, below the 900 s request timeout) or RENDER_MAX_BODY_BYTES, checked against the backend's ranges before deploying. Repeatable, once per name.
--demo-only off DEMO_ONLY=true: a public demo without sign-in. Service name gcportrait-demo unless --service is given; only the run, iam and artifactregistry APIs; 256 MiB and 60 s per request instead of 2 GiB and 900 s; no render API. Excludes --oauth-client-id, --sa-mode, --sa-strict-exposure, --iap, --no-render-api and --render-env.
--domain HOST unset A Cloud Run domain mapping of HOST to the service with a Google-managed certificate, created through the Cloud Run Admin API (curl; no gcloud beta needed). Only in asia-east1, asia-northeast1, asia-southeast1, europe-north1, europe-west1, europe-west4, us-central1, us-east1, us-east4, us-west1 (DOMAIN_MAPPING_REGIONS in infra/common.sh). Prints the DNS records, checks them in public DNS and waits for the certificate at most ten times 30 s. Kept on a redeploy without it. Excludes --iap.
--impressum-url URL unset LEGAL_IMPRESSUM_URL: link the Impressum on your own site instead of serving a text; an https:// URL, checked as the backend checks it (deploy.sh also wants a host of letters, digits, dots and dashes, and no white space). Refused next to an impressum.md in --legal-dir.
--privacy-url URL unset LEGAL_PRIVACY_URL, likewise; refused next to a privacy.md in --legal-dir.
--legal-dir PATH unset LEGAL_BUNDLE from PATH/impressum.md and/or PATH/privacy.md, checked as the backend checks them, at most 11 KiB each (both travel in one environment variable).
--dry-run off Prints every change (+ gcloud ...) without making it; read-only queries still run.
--verbose off Also prints what the check of the running instance saw: the ready revision and the answers of /api/healthz, /api/config and /. Without it, one line says that the instance answers, with its version.
-h, --help Usage.

Fixed Cloud Run settings (change them with gcloud run services update only if you accept that the next deploy.sh run resets them): container port 8080, 1 CPU, memory from --memory (2 GiB; 256 MiB with --demo-only), concurrency 80, min instances 0, request timeout 900 s (60 s with --demo-only), startup CPU boost, ingress all, startup probe GET /healthz every 2 s (up to 15 failures), liveness probe GET /healthz every 30 s (3 failures), service account = the backend account, label managed-by=gcportrait-deploy. The probes reach the container directly; from outside, Cloud Run's front end answers /healthz itself (it keeps paths ending in z), so deploy.sh's own check waits on GET /api/healthz, which answers the same.

APIs it enables when they are off: run, iam, cloudasset, cloudresourcemanager, artifactregistry (with --demo-only only run, iam, artifactregistry); with --build cloudbuild and storage; with --iap iap (all .googleapis.com). Each one it enabled is recorded as the label gcportrait-api-NAME=enabled on the service and, with --build, on the build repository.

Retries. deploy.sh repeats a call only when Google refuses it the way it does for a minute or two after a service account or a grant is new: a grant naming an account that "does not exist" yet (up to 6 tries, 15 s apart), a Cloud Build submission refused before a build exists (PERMISSION_DENIED, or FAILED_PRECONDITION for the bucket; up to 5 tries, 30 s apart), a deploy that cannot see the new backend account yet (up to 4 tries, 20 s apart). A build that ran and failed, or a revision that does not start, ends the run at once.

infra/uninstall.sh

infra/uninstall.sh --project PROJECT_ID [flags]

Flag Default Effect
--project ID required The project the instance runs in.
--region REGION europe-west3 Its region.
--service NAME gcportrait Its service name (the other names derive from it, as for deploy.sh).
--disable-apis off Also disables the APIs recorded on the service and the build repository as enabled by deploy.sh, one at a time, through the Service Usage API without disabling dependent APIs (one that another enabled API depends on is kept). Google's check for recent use is skipped (checkIfServiceHasUsage: SKIP), so pass it only when nothing else in the project uses those APIs.
--yes off No confirmation prompt; required without a terminal.
--dry-run off Lists what it would delete and prints the commands; deletes nothing.
-h, --help Usage.

Names and labels

For --project PROJECT, --region REGION and --service SERVICE (infra/common.sh, derive_names):

Resource Name Marked by Created
Cloud Run service SERVICE in REGION label managed-by=gcportrait-deploy always
Backend service account SERVICE-backend@PROJECT.iam.gserviceaccount.com description starting "Created by gcportrait infra/deploy.sh for the Cloud Run service SERVICE" unless it exists
Build service account SERVICE-build@PROJECT.iam.gserviceaccount.com the same description form --build
Docker repository REGION-docker.pkg.dev/PROJECT/SERVICE-builds, cleanup policy: keep the 5 newest label managed-by=gcportrait-deploy (and the gcportrait-api-NAME=enabled labels) --build
Build bucket gs://PROJECT-SERVICE-builds in REGION: uniform access, public access prevention, no soft delete, objects deleted after 14 days label managed-by=gcportrait-deploy, set in the same request that creates the bucket; bucket names are global, so deploy.sh uses an existing one, and uninstall.sh deletes it, only when it also belongs to PROJECT --build
Images REGION-docker.pkg.dev/PROJECT/SERVICE-builds/gcportrait:UTCDATE-UTCTIME-VERSION in the repository each --build
Remote repository REGION-docker.pkg.dev/PROJECT/ghcr (REMOTE_REPO), a Docker remote repository of https://ghcr.io (REMOTE_UPSTREAM), one per project and region, shared by the instances there; it keeps every image it fetched label managed-by=gcportrait-deploy; an unlabelled remote repository of ghcr.io of that name is used but never changed or deleted. uninstall.sh deletes a labelled one only when no other Cloud Run service or job of the project has an image from it without --build, or --image with a release tag or a ghcr.io/... image, when it does not exist yet
Domain mapping HOST in REGION, routing to SERVICE the service it routes to (uninstall.sh deletes the mappings to the service it deletes) --domain

IAM bindings deploy.sh adds, all on resources it created: the build account gets roles/storage.admin on the bucket (the role Google documents for a build account with logs in a bucket of the user's; Cloud Build reads the bucket itself before it accepts a build) and roles/artifactregistry.writer on the repository; allUsers (public) or the IAP service agent (--iap) gets roles/run.invoker on the service; each --iap-members principal gets roles/iap.httpsResourceAccessor on the service. Nothing at project level or above.

The image

infra/Dockerfile, built from the repository root (.dockerignore keeps the context to the workspace sources).

Build arguments APP_VERSION (default dev; the release workflow passes the tag, or dev-SHA when run by hand, deploy.sh --build the output of git describe, the Cloud Run Button's hook git describe --tags --always)
Base images node:22.23.3-bookworm-slim (build stage) and gcr.io/distroless/nodejs22-debian12:nonroot (runtime), both pinned by digest on literal FROM lines, which Dependabot keeps up to date (.github/dependabot.yml)
Environment NODE_ENV=production, PORT=8080, STATIC_DIR=/app/frontend/dist, APP_VERSION from the build argument
Files /app/backend/dist/server.mjs (the backend and its npm dependencies bundled by backend/bundle.ts), its source map, /app/backend/dist/render-worker.mjs and its map (the render API's worker, started per render: api.md), /app/backend/dist/render-assets/ (resvg's WebAssembly module and the Roboto faces it draws with, with their licences), /app/backend/dist/licenses/server-dependencies.txt, /app/backend/package.json, /app/frontend/dist/ (the SPA with its licenses/, THIRD_PARTY_NOTICES.txt among them: every third-party package in the app and the server with its notices, served at /licenses/THIRD_PARTY_NOTICES.txt; the build fails without it), /app/LICENSE (MIT, with its note on the gcportrait name and icon), /app/brand/LICENSE.md, /app/THIRD_PARTY.md
Command node --enable-source-maps /app/backend/dist/server.mjs (the base image's entrypoint is node)
User 65532:65532 (the base image's nonroot); nothing in /app is writable, so the container runs with a read-only root filesystem
Port 8080
Health HEALTHCHECK: Node fetches http://127.0.0.1:$PORT/healthz every 30 s (Docker, compose). Cloud Run ignores it and uses deploy.sh's startup and liveness probes on the same path, which reach the container directly; from outside a Cloud Run service, /healthz gets the front end's own 404, so check /api/healthz there.
Labels org.opencontainers.image.title=gcportrait, .description = the long form of the non-affiliation statement (ADR 0018), .licenses=MIT, .version = APP_VERSION; the release workflow adds source, revision and creation time

Local runs with compose

infra/compose.yaml (pnpm docker:build, then pnpm docker:run). Compose reads these from the shell or from infra/.env (git-ignored):

Variable Default Effect
GCPORTRAIT_IMAGE gcportrait:local The image to run (pnpm docker:build builds this tag).
GCPORTRAIT_PORT 8080 Host port, bound to 127.0.0.1 only.
OAUTH_CLIENT_ID, HOSTED_BY, SOURCE_URL, DEMO_ONLY, LOG_LEVEL unset, info Passed to the backend as above.
RENDER_API, RENDER_MAX_ASSETS, RENDER_MAX_PIXELS, RENDER_TIMEOUT_MS, RENDER_MAX_BODY_BYTES unset Passed to the backend as above: the render API is on, with its default limits.

Fixed: SA_MODE_ENABLED=false (the container has no service-account identity), TRUST_PROXY_HOPS=0, read-only root filesystem, all capabilities dropped, no-new-privileges.

Cloud Run Button

The "Run on Google Cloud" button (self-host-options.md, "Run on Google Cloud") reads app.json at the repository root. It asks for two variables and deploys with fixed options; its postcreate hook, infra/cloud-run-button.sh, then adds what the button cannot set.

Item Value
Asked: OAUTH_CLIENT_ID default none (the button does not accept an empty answer); the hook removes it while it is none
Asked: SA_MODE_ENABLED default false
Options of app.json public (allow-unauthenticated), 1 CPU, 2 GiB (the render API's default limits fit it), port 8080, 80 concurrent requests, at most 10 instances; the image is built from infra/Dockerfile in Cloud Shell (prebuild hook, APP_VERSION from git describe --tags --always)
Set by the hook TRUST_PROXY_HOPS=1, request timeout 900 s, startup CPU boost, at most 10 instances (the button's own setting does not apply), the startup and liveness probes on GET /healthz
The hook reads GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_REGION, K_SERVICE, SERVICE_URL (the button sets them), and OAUTH_CLIENT_ID

The service runs as the project's Compute Engine default service account and carries no managed-by label, so deploy.sh and uninstall.sh leave it alone. Run against the same project and region, deploy.sh first enables whichever of the APIs it needs are still off (the hook enables none), then stops before it creates or deploys anything. It prints the gcloud run services update … --update-labels=managed-by=gcportrait-deploy command that hands the service over and, when it enabled APIs, the gcloud services disable … command for them in case you give up instead. Once you have run the hand-over command yourself, deploy.sh manages the service (self-host-options.md, "Run on Google Cloud").

Release workflow

.github/workflows/release.yml on v* tags, and by hand (workflow_dispatch) for a dev image. It needs no secret and no repository variable: the workflow's own GITHUB_TOKEN (packages: write for the image, contents: write for the release) does everything. contributing.md, "9. Releases" has the one-time step (making the package public).

Item Value
Image ghcr.io/OWNER/gcportrait (OWNER the repository's owner, lowercase), linux/amd64 and linux/arm64; equal to PUBLISHED_IMAGE in infra/common.sh for the project's own repository
Tags X.Y.Z, X.Y, and latest when the tag is the newest release (the highest vX.Y.Z tag; never a pre-release); dev when run by hand
Checks before the push each platform's image is started: /healthz reports the version (the tag, or dev-SHA), /api/config and the SPA answer
After the push an anonymous pull check (fails a tag release while the package is not public), one SPDX SBOM per platform (Syft)
GitHub release vX.Y.Z with notes from scripts/release-notes.mjs (the version's CHANGELOG.md sections, the long non-affiliation statement, the installer line, the image's tags and digest); tags only
Release assets the SBOMs; gcportrait-deploy-vX.Y.Z.tar.gz, the deploy scripts infra/install.sh downloads (VERSION and infra/ with deploy.sh, common.sh, uninstall.sh, install.sh, README.md); checksums.txt, the SHA-256 of every other asset (scripts/release-assets.sh writes the last two)

SPA build

Read by Vite when the frontend is built (frontend/README.md); the image builds the SPA without them.

Variable Default Effect
VITE_DEMO unset 1 builds or serves the demo-only app (no backend calls); pnpm dev:demo sets it.
VITE_SOURCE_URL https://github.com/florianchrometz/gcportrait The footer's source link when no backend answers (demo mode, the static demo); the image's app takes SOURCE_URL from /api/config.
VITE_STATIC_DEMO unset 1 builds the static demo: demo-only mode with its configuration built in instead of /api/config, below /demo/, for a static host (ADR 0034). pnpm demo:static sets it with the three below; not together with VITE_DEMO.
VITE_APP_VERSION git describe (by pnpm demo:static) The static demo's version (--app-version).
VITE_LEGAL_IMPRESSUM_URL the maintainer's Impressum (by pnpm demo:static) The Impressum the static demo's footer links (--impressum-url; an https:// URL, anything else links nothing), as LEGAL_IMPRESSUM_URL does for an instance.
VITE_LEGAL_PRIVACY_URL the maintainer's privacy notice (by pnpm demo:static) The same for the privacy notice (--privacy-url).

This page is docs/configuration.md in the repository, rendered for the website.