Self-hosting options
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_PROJECTThe id of the project gcportrait runs in: deploy.sh’s
--project. Example:demo-gcportraitExpected: 6 to 30 lowercase letters, digits or hyphens, starting with a letter and not ending with a hyphen.
-
YOUR_OAUTH_CLIENT_IDThe client id of your OAuth client for gcportrait (never its secret). Example:
10000000001-abcdefghij0123456789.apps.googleusercontent.comExpected: digits, a hyphen, letters and digits, then .apps.googleusercontent.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.
Everything the self-hosting guide leaves at its default, and how to run, check and remove an instance. Each option says what it is for, its flag, its default and gives an example; none of them is needed for a working instance. configuration.md is the reference of every flag and environment variable, modes.md says what an instance offers its users, and permissions.md what they need to read a scope.
| Section | For |
|---|---|
| Running the commands on this page | Where to run them, and how a flag reaches deploy.sh. |
| The installer | What install.sh downloads and checks before it runs anything, its own options, its limits. |
| Region and service name | Another region; several instances in one project. |
| Memory, instances and logs | Memory per instance, the most instances, the log level. |
| Who runs the instance | The "Hosted by" line people see before they sign in. |
| The OAuth client in detail | Every console screen of "Sign in with Google", sign-in errors, and what enabling sign-in makes you responsible for. |
| Private instance: Identity-Aware Proxy | Only listed people can open the instance. |
| Service-account mode | A team grants the instance's account read access once, instead of every person. |
| Demo-only instance | A public, sign-in-free demo of your own with the demo organisations only. |
| Custom domain | Your own host name, with a Google-managed certificate, and the DNS records to create. |
| Legal pages: link or serve | An Impressum and a privacy notice: links to your own pages, or texts the instance serves. |
| The render API | Turning POST /api/render off, or changing its limits. |
| Updating, pinning and rolling back | Image tags, releases, Cloud Run's revisions. |
| Building from source | The "Run on Google Cloud" button, --build from a clone, the image on your machine. |
| What deploy.sh does | Every API, account, role and resource it touches, what it never does, and the roles you need to run it. |
| Uninstall in detail | What uninstall.sh deletes and keeps, and --disable-apis. |
| Costs, Logs, Troubleshooting |
Running the commands on this page
The commands below name the scripts as infra/deploy.sh and infra/uninstall.sh: run them in the folder the
installer kept (cd gcportrait-vX.Y.Z), or as gcportrait-vX.Y.Z/infra/deploy.sh from where you ran the
installer (in a clone of the repository, from its root). Without --image, deploy.sh deploys the newest
release, so a release you pinned with the installer's --version stays pinned only when these commands carry
--image vX.Y.Z, as the "Redeploy with the same settings" line deploy.sh prints at its end does.
Every deploy.sh flag also works on the installer line, which passes it on and deploys the newest release, for
example curl -fsSL https://gcportrait.app/install.sh | bash -s -- --project YOUR_PROJECT --region europe-west1. deploy.sh is declarative: each run sets the service to what its flags say, and a flag left out
gets its default, so keep the flags of your instance on every run and add the new one. --dry-run prints
every change without making it.
The gcloud commands on this page use the defaults, the service gcportrait in europe-west3: if you
deployed with --service or --region, put yours in their place.
The installer
https://gcportrait.app/install.sh is infra/install.sh, served by the website as it
is in the repository. It prints each step as it goes:
- It checks for curl, tar,
sha256sumorshasum, and gcloud, and names the one that is missing. - It finds the newest release with GitHub's API, or, when the API refuses (it allows 60 requests an hour
without a token), from where
github.com/florianchrometz/gcportrait/releases/latestleads.--version vX.Y.Znames a release instead. - It downloads the release's
gcportrait-deploy-vX.Y.Z.tar.gz, the deploy scripts (infra/deploy.sh,common.sh,uninstall.sh,install.shandREADME.md, andVERSIONwith the tag), and itschecksums.txt. - It checks the archive's SHA-256 against
checksums.txtbefore it unpacks anything. When a download fails, the list does not name the archive or the checksum differs, it stops: nothing is unpacked or run. - It keeps the scripts in
gcportrait-vX.Y.Zin the current directory. A folder that holds the same release already is used as it is; one with other files is left alone, and the scripts go togcportrait-vX.Y.Z-2. - It runs
gcportrait-vX.Y.Z/infra/deploy.sh --image vX.Y.Zwith every other argument, so the instance runs the release the scripts came from. Its exit status is deploy.sh's.
| Option | Default | What it does |
|---|---|---|
--version TAG |
the newest release | Deploys that release, such as v1.2.3: its deploy scripts and its image. |
--dir PATH |
gcportrait-TAG in the current directory |
Keeps the scripts there: a new or empty directory, or one that holds the same release. |
Every flag of deploy.sh passes through; --image replaces the release's own image, and --build is refused,
as it builds the image from a clone of the source (Building from source).
Its limits: it needs a published release that carries the deploy archive and checksums.txt, and stops before
anything runs when the release lacks either. In a browser, https://gcportrait.app/install.sh downloads as a
file instead of showing, because GitHub Pages serves it as a shell script; curl reads it either way. To read
the script before you run it, download it first:
Edit a highlighted value in any command; every command on this page follows.
curl -fsSL https://gcportrait.app/install.sh -o install.sh
bash install.sh --project YOUR_PROJECT --dry-run
Region and service name
The region decides where the service runs, and with --build where the image is built and kept; the service
name decides every name deploy.sh derives (the account, the build repository and bucket), so several instances
can share a project.
| Flag | Default | |
|---|---|---|
--region REGION |
europe-west3 |
Any Cloud Run region; a custom domain needs one with domain mappings. |
--service NAME |
gcportrait (gcportrait-demo with --demo-only) |
At most 22 characters: lowercase letters, digits, dashes. |
infra/deploy.sh --project YOUR_PROJECT --region europe-west1 --service gcportrait-eu
uninstall.sh needs the same --region and --service to find the instance again.
Memory, instances and logs
| Flag | Default | |
|---|---|---|
--memory SIZE |
2Gi (256Mi with --demo-only) |
Memory of each instance, 512Mi to 4Gi. The render API's defaults fit 2 GiB; on less, turn it off or lower its limits (The render API). |
--max-instances N |
10 (2 with --demo-only) |
The most Cloud Run instances at once, 1 to 999. A low number caps what a public instance can cost. |
--log-level LEVEL |
info |
fatal, error, warn, info, debug, trace or silent (Logs). |
infra/deploy.sh --project YOUR_PROJECT --memory 1Gi --max-instances 3 --no-render-api
The other Cloud Run settings (1 CPU, 80 concurrent requests, the 900 s request timeout, the probes) are fixed; configuration.md lists them.
Who runs the instance
--hosted-by "NAME" (default: unset) shows who runs the instance before people sign in, as "Hosted by NAME.":
your team's or company's name, plain text on one line, at most 80 characters, without < or >, never naming
Google. deploy.sh checks it as the backend does before it deploys. Add it to the command you deploy with, as
any other flag.
The OAuth client in detail
"Sign in with Google" uses Google Identity Services' token client in the browser. It needs an OAuth 2.0 client of the type Web application, created in the Google Cloud project that runs your instance, and nothing else:
- Only the client id is used. It is public: the backend reports it in
GET /api/configand the browser hands it to Google. The client secret is never used; do not copy it anywhere. - Authorised JavaScript origins must list every address people open the instance at.
- No redirect URI. The token client never redirects: Google's sign-in window hands the token back to the page that opened it. Leave "Authorised redirect URIs" empty.
- Three scopes, which the app requests when someone signs in:
https://www.googleapis.com/auth/cloud-platform.read-only(Resource Manager: the scope picker, the preflight and the service-account gate),https://www.googleapis.com/auth/cloudasset(Cloud Asset Inventory refuses a token without it orcloud-platform; ADR 0028 §6) andhttps://www.googleapis.com/auth/userinfo.email(the account menu shows the email address).
Google offers no command-line or API way to create this kind of client, so it is the one console step. In the Google Cloud console the consent screen and the clients live under Google Auth Platform; menu names change from time to time, the settings do not.
For your own instance (a company, a team, yourself)
-
Open the Google Cloud console and select the project that runs gcportrait (the one with the Cloud Run service). deploy.sh prints the direct link,
https://console.cloud.google.com/auth/clients?project=YOUR_PROJECT. -
Open Google Auth Platform (search for it, or APIs & Services › OAuth consent screen). If the project has no consent screen yet, choose Get started:
- App information. App name:
gcportrait, or a name of your own that does not contain "Google". User support email: an address of yours. - Audience. Internal when only accounts of your own Google Workspace or Cloud Identity organisation will sign in (the project must belong to that organisation; Google does not review Internal apps). External otherwise; it starts in testing, where only the test users you list (up to 100) can sign in.
- Contact information. An address Google can write to about the app.
- Accept the Google API Services User Data Policy and choose Create.
- App information. App name:
-
Data Access › Add or remove scopes › Manually add scopes: paste the three scopes above, one per line, choose Add to table, then Update and Save. The page lists which of them Google treats as sensitive or restricted.
-
Clients › Create client:
- Application type: Web application. Name: for example
gcportrait web. - Authorised JavaScript origins › Add URI, once per address, as scheme and host (and port, when
there is one), with no path and no trailing slash:
- the Cloud Run address deploy.sh printed,
https://SERVICE-PROJECT_NUMBER.REGION.run.app, for examplehttps://gcportrait-123456789012.europe-west3.run.app(Cloud Run also answers on an olderhttps://SERVICE-HASH-REGION_CODE.a.run.appaddress, which deploy.sh prints as "Also answers on"; add it too if people use it); - your own domain, if you map one to the service (Custom domain), for example
https://gcportrait.example.com; - for development only:
http://localhost:5173(pnpm dev) andhttp://localhost:8080(the backend serving a build, or the image run locally). Google allows plainhttpforlocalhostonly.
- the Cloud Run address deploy.sh printed,
- Authorised redirect URIs: none.
- Choose Create and copy the Client ID,
PROJECT_NUMBER-RANDOM.apps.googleusercontent.com. Close the dialog without downloading the secret.
- Application type: Web application. Name: for example
-
Give the client id to the instance: run deploy.sh again with your flags plus
--oauth-client-id, and--hosted-by "NAME"if you want to show who runs it (Who runs the instance):infra/deploy.sh --project YOUR_PROJECT --oauth-client-id YOUR_OAUTH_CLIENT_IDFor local development put both into
backend/.env(seebackend/.env.example). -
Check: the instance's address with
/api/configadded names the client id, and the Connect page's "Sign in with Google" is no longer marked "not configured". Sign in once: Google's window lists the three permissions, and afterwards the account menu at the top right shows your email address.
If sign-in fails at once. When the app says Google's window "was closed before the sign-in finished, or
Google showed an error in it", look at Google's window: an error such as origin_mismatch or
redirect_uri_mismatch means the address in your browser's address bar is not among the client's authorised
JavaScript origins (compare scheme, host and port exactly; changes can take a few minutes to apply), and
invalid_client means OAUTH_CLIENT_ID does not name a client of this project. When the app says the browser
blocked Google's window, allow pop-ups for the instance's address.
Signing out. "Sign out" revokes gcportrait's access with Google, and Google's revocation withdraws the account's whole grant to the instance's OAuth client, not just one token: other tabs signed in with the same account end their session at their next request ("Session expired"), and the next sign-in shows Google's consent screen again.
There is no hosted sign-in instance of gcportrait (ADR 0035 §2, ADR 0032): the project hosts its website and a sign-in-free demo, and whoever wants to read their own Google Cloud runs their own instance with their own OAuth client, as above.
If you enable sign-in
You are the operator of your instance, not the gcportrait project. With an OAuth client, people sign in to it with their Google accounts: their tokens pass through your backend to Google, the assets they read stream through it to their browsers, Cloud Run's request log records their IP addresses (Logs), and in service-account mode an audit line records a hash of each caller's email (Service-account mode). Whatever notices and obligations come with offering that to others are yours. What Google asks of you depends on the audience of the consent screen (Manage App Audience):
- Internal: only accounts of your own Google Workspace or Cloud Identity organisation can sign in, and the app needs no verification by Google: its page on sensitive scope verification lists use only within your organisation among the cases that need none.
- External: in testing, only the test users you list (up to 100) can sign in. To let any Google account sign in, you publish the app to production, and because gcportrait asks for scopes Google treats as sensitive (the Data Access page marks them), Google's verification applies: until it is complete, people see the unverified app screen and the app is limited to 100 new users. Verification asks, among other things, for a privacy policy that is hosted on the domain of the app's home page, is linked from the consent screen and says how the app accesses, uses, stores and shares Google user data (verification requirements).
The project offers no such privacy policy: docs/legal/ lists only what the project's website and sign-in-free demo process, which is no Google user data. Check Google's pages above before you rely on them: they change from time to time.
Private instance: Identity-Aware Proxy
Optional. By default the service is public, which is safe in the sense that reading any Google Cloud data
still needs a person's own sign-in and roles. --iap (default: off) makes it private instead: Cloud Run's
built-in Identity-Aware Proxy (IAP) lets only the principals of --iap-members (user:, group:,
serviceAccount: or domain:; default: your own gcloud account) open it, and signs them in with Google
before the app loads.
infra/deploy.sh --project YOUR_PROJECT --iap --iap-members user:ana@example.com,group:cloud-team@example.com
deploy.sh enables the IAP API, creates IAP's service agent, allows it to invoke the service, and grants each
principal roles/iap.httpsResourceAccessor on the service. People still sign in to the app itself to read
Google Cloud data with their own roles (user mode), or use service-account mode
inside.
- Principals are added, never removed, so a redeploy cannot lock anyone out by accident. To remove one:
gcloud iap web remove-iam-policy-binding --project=YOUR_PROJECT --resource-type=cloud-run --service=gcportrait --region=europe-west3 --member=user:ana@example.com --role=roles/iap.httpsResourceAccessor. - IAP's Google-managed sign-in lets in accounts of the project's own organisation; for accounts outside it, IAP needs an OAuth client of your own (see Google's IAP documentation for Cloud Run).
- A redeploy without
--iapmakes the service public again. - User mode behind IAP is designed to work (ADR 0031, proposed):
IAP treats the
Authorizationheader as the place for its own credential, so the app never puts the person's Google token there: every/api/*call carries it in a header of gcportrait's own,X-Gcportrait-Token, a header IAP has no use for and is meant to pass on as sent. IAP's cookie decides who may open the instance; the token and the person's own IAM roles decide what they may read. The backend still acceptsAuthorization: Bearer TOKENfrom a request withoutX-Gcportrait-Token, the path for scripts against a public instance (behind IAP, such a script needs IAP's own credentials as well, see Google's IAP documentation on programmatic access). Neither header is ever logged, and the token header is not a cookie: the browser neither stores it nor sends it by itself. - Try
--iapwith--dry-runfirst, which prints every step of it, the IAP service-identity request included. If, behind IAP, every API call answers 401 "Invalid IAP credentials", the app's requests still carry anAuthorizationheader, as builds before the token header did: redeploy a current release.
Service-account mode
In user mode (the default) every person reads what their own Google account may read. In service-account mode
a team grants the instance's backend account read access to a scope once, and people who can see that scope
and sign in to the instance read it through the account. Deploy with --sa-mode (default: off); a scope's
owner then grants the account roles/cloudasset.viewer on that scope, and nothing more. deploy.sh prints the
account as "Service account" (gcportrait-backend@ and the project's id), the app's Permissions page shows it
too, and permissions.md, "The grant" has the commands for an organisation, a
folder and a project.
infra/deploy.sh --project YOUR_PROJECT --oauth-client-id YOUR_OAUTH_CLIENT_ID --sa-mode
- The gate. Before every call as the account, the backend checks that the signed-in person can
getthe scope with their own token; there is no setting that skips it (ADR 0028 §4).--sa-strict-exposurealso refuses a scope whose details the account can read itself (a publicget, or more roles thancloudasset.viewer): see backend/README.md, "Service-account mode". - Prefer folders and projects to the organisation. Anyone who can see the scope and sign in can read its
whole inventory through the account, including projects they cannot open themselves; in many organisations
every member of the domain can see the organisation itself. Grant
roles/cloudasset.vieweronly: a role withresourcemanager.*.get(Browser, Viewer) on the scope makes the strict exposure check refuse it. - The account's calls are charged to the instance's project, which is why deploy.sh enables the Cloud Asset and Resource Manager APIs there; the account needs no role in its own project for that.
- On a laptop, service-account mode needs the account's credentials through Application Default Credentials; see backend/README.md. The local image runs without it.
Demo-only instance
A demo-only instance lets people try gcportrait without signing in and without any Google Cloud data: the app
with its synthetic demo organisations S, M, L and XL (generated in each visitor's browser and kept there), the
timeline, search and export, and nothing else. This is for your own instance, for example to show gcportrait
inside your company with your own legal pages (ADR 0032). The
project's own demo at https://gcportrait.app/demo/ (opens in a new tab) is not one: it is a static build of the app with the same
behaviour, served by the website's GitHub Pages site without any server (ADR
0034; pnpm demo:static, website.md, "The demo"). A
static build of your own, for any static host, works the same way: pnpm demo:static --base /PATH/ --impressum-url URL --privacy-url URL writes it to dist/demo/; it has no response headers, so its Content
Security Policy is a <meta> without frame-ancestors, and it is not cross-origin isolated.
--demo-only (default: off):
infra/deploy.sh --project YOUR_PROJECT --region europe-west1 --demo-only
What it changes:
- The backend runs with
DEMO_ONLY=true./api/configreports"demoOnly":true, no OAuth client, no service-account mode and no account. The four routes that call Google (/api/scopes/search,/api/preflight,/api/assets/stream,/api/export/read) are not registered, so they answer a JSON 404 like any unknown route; the rate limits, the health checks,/api/config,/api/legaland the app stay. The Content Security Policy allows no Google host. AnOAUTH_CLIENT_IDorSA_MODE_ENABLED=truenext toDEMO_ONLY=truestops start-up. - The app shows a Connect page with only "Try the demo" (S to XL), a link "Deploy gcportrait to your own
Google Cloud project" to the self-hosting guide on
gcportrait.app, and a note that the demo processes no personal data; the top bar carries the "Demo mode" badge. - deploy.sh names the service
gcportrait-demounless you pass--service, enables only therun,iamandartifactregistryAPIs, gives the service its own account with no role (as for any instance; it publishes it nowhere), and sets 256 MiB, 60 s per request and at most 2 instances (--max-instancesoverrides it). The service is public. It refuses--oauth-client-id,--sa-modeand--iaptogether with--demo-only.
Check it from outside (its address with /api/config added says "demoOnly":true), and remove it with
infra/uninstall.sh --project YOUR_PROJECT --region europe-west1 --service gcportrait-demo. Without Cloud
Run, build the app once with pnpm build, then run DEMO_ONLY=true pnpm --filter @gcportrait/backend start
and open http://localhost:8080; with compose, put DEMO_ONLY=true into infra/.env. For a demo on your
laptop without any backend there is also pnpm dev:demo (the build-time demo mode), and for one on a static
host the static build above. A public demo usually needs an Impressum and a privacy notice: Legal pages: link
or serve (deploy.sh's next steps say how when a demo-only deploy has neither).
Custom domain
--domain HOST (default: unset) serves the instance at a host name of yours as well, with a certificate
Google manages, through a Cloud Run domain mapping:
infra/deploy.sh --project YOUR_PROJECT --region europe-west1 --demo-only --domain demo.example.com
- A region with domain mappings. Cloud Run offers them in
asia-east1,asia-northeast1,asia-southeast1,europe-north1,europe-west1,europe-west4,us-central1,us-east1,us-east4andus-west1(Google's list), not ineurope-west3; deploy.sh refuses--domainelsewhere before it changes anything. Google calls domain mappings a Preview, "not production-ready", because of latency. - Verify the domain in Google Search Console with the account gcloud uses:
gcloud domains verify example.comopens Search Console; choose the Domain property and create the TXT record it shows at your DNS provider.gcloud domains list-user-verifiedlists it once Google has seen the record. Verifyingexample.comcovers every name under it. Until then deploy.sh deploys the service, skips the mapping and prints these steps. - Run deploy.sh with
--domain. It creates the mapping and prints the DNS record Google asks for, in zone-file form and as most DNS providers ask for it, for a subdomain typicallydemo.example.com. 3600 IN CNAME ghs.googlehosted.com.(namedemoin the zoneexample.com). Create it. deploy.sh looks the record up in public DNS (Google Public DNS over HTTPS); once it is there, Google provisions the certificate, usually within 15 minutes and up to 24 hours. deploy.sh waits for it at most ten times 30 s and says where things stand; nothing more needs to run. Check later withcurl -sI https://demo.example.com/api/healthz, with your host name. - Sign-in instances: add
https://and your host name to the OAuth client's authorised JavaScript origins (deploy.sh lists it among the origins).
When the host is mapped to another service, deploy.sh stops before it creates or deploys anything (the APIs
it needs may be enabled by then; it names those it enabled and the command that disables them) and prints the
command that frees the host. The mapping stays when you redeploy without --domain; infra/uninstall.sh
deletes every mapping that routes to the service it deletes. The DNS records are yours to remove.
A region without domain mappings, or a production instance: put a global external Application Load Balancer with a serverless network endpoint group and a Google-managed certificate in front of the service (Google's guide "Set up a global external Application Load Balancer with Cloud Run"). deploy.sh does not automate it. Its forwarding rule alone costs about $18 a month ($0.025 an hour), which is why deploy.sh offers a domain mapping in a region that has them instead (ADR 0032).
Legal pages: link or serve
Whoever runs a public instance may have to name themselves and say what happens with personal data (in Germany, an Impressum and a privacy notice; even a sign-in-free demo's hosting keeps request logs with visitors' IP addresses). gcportrait offers two ways, for each page on its own (default: neither):
- Link pages you publish elsewhere, such as on your own website, with
--impressum-urland--privacy-url(LEGAL_IMPRESSUM_URL,LEGAL_PRIVACY_URL). Each must be anhttps://URL without a user name or password, at most 2048 characters; deploy.sh checks that as the backend does. The backend reports them in/api/config, aslegal.impressumUrlandlegal.privacyUrl(null when unset), and the app's footer links them as "Impressum · Privacy" (each in the same tab, marked as a link to another site; the Connect page of a demo-only instance links the privacy notice too, and/impressumand/privacylead there). - Serve texts you keep outside the repository, with
--legal-dir: the instance shows them at/impressumand/privacy, linked as "Impressum · Privacy" in the footer of every page, next to the non-affiliation notice and the source link (below).
infra/deploy.sh --project YOUR_PROJECT --region europe-west1 --demo-only \
--impressum-url https://example.com/legal/impressum --privacy-url https://example.com/legal/privacy
A page is linked or served, not both: deploy.sh refuses --impressum-url next to an impressum.md in
--legal-dir (and --privacy-url next to a privacy.md) before it changes anything, and the backend refuses
to start with LEGAL_IMPRESSUM_URL while LEGAL_DIR or LEGAL_BUNDLE holds an Impressum (likewise for the
privacy notice). Linking one page and serving the other is fine. /api/config's legal.impressum and
legal.privacy stay true only for a page the instance serves.
The project's own website and its demo at gcportrait.app/demo/ take the first way and link the maintainer's
pages; the repository holds no legal texts, and docs/legal/ lists what those two surfaces
process. A self-hosted instance is its operator's: link your own pages or serve your own texts, never the
maintainer's.
Serving texts:
- Write
impressum.mdand/orprivacy.md(UTF-8 Markdown) in a directory of their own, for example~/gcportrait-legal/. Either one alone is fine; the footer links only what exists. - Deploy with
--legal-dir ~/gcportrait-legal(any instance, demo-only or not). deploy.sh checks the texts as the backend does (UTF-8, no control characters, not blank, at most 11 KiB each) and sets them as one environment variable,LEGAL_BUNDLE. deploy.sh is declarative: a redeploy without--legal-dirremoves them (and a redeploy without--impressum-urlor--privacy-urlremoves those links). - Without deploy.sh, point
LEGAL_DIRat the directory: afterpnpm build,LEGAL_DIR=~/gcportrait-legal pnpm --filter @gcportrait/backend start, thencurl -s http://localhost:8080/api/legalshows both texts. In a container, mount the directory read-only and setLEGAL_DIRto its path. Links work the same way:LEGAL_IMPRESSUM_URL=https://… pnpm --filter @gcportrait/backend start.
The app renders a small part of Markdown and never any HTML: headings (# to ######, or a line of = or
- under the text), paragraphs, bullet and numbered lists, **bold**, *italic*, `code`, links to
https:, http:, mailto: addresses or paths on the instance ([Privacy](/privacy)), <https://…> and
<name@example.com>, and lines ---. A line ends inside a paragraph only with two spaces or a backslash at
its end (as in an address block); otherwise lines join into one paragraph. HTML shows as its characters, and a
link to anything else (javascript:, data:) keeps only its text. A text that starts with # Title gives
the page its title; otherwise the page is called "Impressum" or "Privacy notice".
The render API
An instance draws any view as SVG, PNG or a draw.io file on the server, for scripts: POST /api/render
(api.md). It is on by default (a demo-only instance never has it).
| Flag | Default | |
|---|---|---|
--no-render-api |
off (the API is on) | Leaves the route out, for an instance where nobody needs it or one with less memory. |
--render-env NAME=VALUE |
the backend's defaults | Changes one of its limits, RENDER_MAX_ASSETS, RENDER_MAX_PIXELS, RENDER_TIMEOUT_MS or RENDER_MAX_BODY_BYTES (configuration.md); repeatable, once per name. |
infra/deploy.sh --project YOUR_PROJECT --render-env RENDER_MAX_PIXELS=134217728 --memory 4Gi
A render keeps the instance's one CPU busy while it runs and needs memory for its PNG, which is why the
largest PNGs want --memory 4Gi. Any Google account's token passes the render route's token check for the
demo and file sources, so on a public instance keep --max-instances low or put Identity-Aware
Proxy in front.
Updating, pinning and rolling back
- A newer release: run the installer line again with your flags. It keeps the new release's scripts in
their own folder, beside the old ones, and deploys its image;
--version vX.Y.Znames a release. - Released images are published to GitHub Container Registry,
ghcr.io/florianchrometz/gcportrait, on every release, forlinux/amd64andlinux/arm64, with the release's version (1.2.3for tagv1.2.3),MAJOR.MINOR, andlatestfor the newest release. deploy.sh pulls them through the remote repositoryghcrin your project.--image v1.2.3and--image 1.2.3both deploy that release (deploy.sh drops thevof a release tag),--image 1.2the newest patch of 1.2 at the time of the deploy,--image ghcr.io/OWNER/NAME:TAGany public ghcr.io image (a fork's) through the same remote repository, and any other full image reference, a digest included, is deployed as it is. Cloud Run records the image digest of each revision, so a revision keeps running the exact image it was deployed with even when a tag moves. A tag that moved within the last hour may still give the image before (the remote repository looks tags up again once an hour), so name the version when you update. - Rolling back: run the installer line again with
--versionand the older release (or deploy.sh with--imageand its tag). Cloud Run also keeps the earlier revisions:gcloud run revisions list --service=gcportrait --project=YOUR_PROJECT --region=europe-west3lists them, and the console's Manage traffic on the service's Revisions tab sends all traffic back to one of them.
The releases and what changed in each are on the releases page, which the release workflow writes from the changesets at every tag.
Building from source
The installer deploys a released image and needs no source. To deploy an image built from the source, a fork's or a modified version, use the button below or a clone of the repository.
Run on Google Cloud
Run on Google Cloud
Built from source, in the browser. Google's Cloud Run
Button opens Cloud Shell, clones this repository and
follows app.json at its root:
- You choose a project (one with billing) and a region.
- It asks for two variables.
OAUTH_CLIENT_ID: keepnoneuntil you have created the OAuth client (the button does not accept an empty answer).SA_MODE_ENABLED: keepfalse(below). - It builds
infra/Dockerfilein Cloud Shell withdocker build(theprebuildhook; the same build Cloud Build runs fordeploy.sh --build), pushes the image to the Artifact Registry repositorycloud-run-source-deployin the region, and deploys the public servicegcportrait: 1 CPU, 2 GiB, 80 concurrent requests, port 8080. - Its
postcreatehook,infra/cloud-run-button.sh, adds what the button cannot set:TRUST_PROXY_HOPS=1, the 900 s request timeout, startup CPU boost, the/healthzprobes and at most 10 instances (app.jsonasks for that limit too, but the button writes it under a misspelt annotation key, which Cloud Run does not apply); it removesOAUTH_CLIENT_IDwhen it isnone, and prints the OAuth client steps with the origin to add and the command that gives the service the client id, with the region you chose (hereeurope-west3):gcloud run services update gcportrait --project=YOUR_PROJECT --region=europe-west3 --update-env-vars=OAUTH_CLIENT_ID=YOUR_OAUTH_CLIENT_ID.
Its limits, next to deploy.sh:
- It builds from source in Cloud Shell, on every click, which takes several minutes on Cloud Shell's small machine. It never deploys a released image; the installer does that without building anything.
- The service runs as the project's Compute Engine default service account, not an account of its own. In
user mode that does not matter (every call uses the signed-in person's token). Service-account mode would
read as that account, which other workloads of the project may share and which may hold Editor on the
project: use
deploy.sh --sa-modefor that. - Nothing else is set: no Identity-Aware Proxy, custom domain, legal pages,
HOSTED_BYorSOURCE_URL, and nomanaged-bylabel. - deploy.sh and uninstall.sh leave the service alone, because deploy.sh did not create it. To manage it
with them, run deploy.sh with the same project and region: it enables whichever of the APIs it needs are
still off, then stops before it creates or deploys anything and prints the one command that hands the
service over; after that it moves the service to an account of its own and keeps it up to date, and
uninstall.sh removes it. To remove it instead:
gcloud run services delete gcportrait --project=YOUR_PROJECT --region=europe-west3(with the region you chose), and its image fromcloud-run-source-deploy. - A second click updates the service with a new build, and keeps the settings the hook added. It asks only
for variables the service does not have yet, so it asks for
OAUTH_CLIENT_IDagain until you have given the service a client id (the hook removed thenoneplaceholder): answer with the client id. Thepostcreatehook does not run again, so anonegiven then stays set and sign-in fails; remove it withgcloud run services update gcportrait --project=YOUR_PROJECT --region=europe-west3 --remove-env-vars=OAUTH_CLIENT_ID.
From a clone of the repository
For contributors, forks and modified versions. A clone has the same infra/deploy.sh, and --build (default:
off) builds the checkout with Cloud Build in your project and deploys that image instead of a release:
git clone https://github.com/florianchrometz/gcportrait.git
cd gcportrait
infra/deploy.sh --project YOUR_PROJECT --build
--build-machine TYPE (default: e2-highcpu-8) picks Cloud Build's machine. The version the instance reports
(/healthz) is git describe of the checkout, and the "Redeploy with the same settings" line deploy.sh
prints deploys the image just built with --image, without building again. contributing.md
sets up a clone for working on gcportrait.
The source link of a modified version. gcportrait is licensed under the MIT License, which asks nothing of
an instance you run, modified or not. The app's footer still links its source as "Source (MIT)", to this
project's repository by default; if you publish the source of your version, pass --source-url (default: this
repository) with where it is, so the link leads there. A modified version you present to others needs its own
name and icon: the gcportrait name and icon are not covered by the MIT License
(brand/LICENSE.md).
Run the image locally
With Docker (or a compatible runtime) on your machine, the released image runs on linux/amd64 and
linux/arm64 alike, without a clone:
docker run --rm --publish 127.0.0.1:8080:8080 ghcr.io/florianchrometz/gcportrait:latest
Then open http://localhost:8080. Demo mode works as is. For sign-in, add --env OAUTH_CLIENT_ID=... (and
--env HOSTED_BY=...) and list http://localhost:8080 among the client's authorised JavaScript origins.
In a clone of the repository, pnpm docker:build builds an image of your checkout for your machine's platform
and pnpm docker:run runs it with infra/compose.yaml, which binds to 127.0.0.1 only and runs the container
read-only, without capabilities; put OAUTH_CLIENT_ID=... into infra/.env there, which git ignores.
The image: a build stage on node:22-bookworm-slim installs the workspace from the lockfile, builds the SPA
and bundles the backend with esbuild into one ESM file (backend/bundle.ts, which explains why); the runtime
stage is gcr.io/distroless/nodejs22-debian12 running as the non-root user 65532, with no shell, no package
manager and no node_modules. Both bases are pinned by digest in infra/Dockerfile. The image's
HEALTHCHECK has Node fetch /healthz (there is no curl); Cloud Run ignores it and uses deploy.sh's probes,
which reach the container directly. From outside a Cloud Run service, check /api/healthz instead (the same
answer): Cloud Run's front end answers /healthz itself.
What deploy.sh does
infra/deploy.sh --help lists every flag; configuration.md has the defaults. Every change
it makes is printed as it runs (+ gcloud ...), and --dry-run prints them without making them. It is safe
to run again at any time, also after it stopped halfway.
| Step | What | Why |
|---|---|---|
| APIs | enables run, iam, cloudasset, cloudresourcemanager and artifactregistry (.googleapis.com); with --build also cloudbuild and storage; with --iap also iap |
Cloud Asset Inventory and Resource Manager are charged to this project for the instance's own calls and for header-less Resource Manager calls made with a person's token through this project's OAuth client (ADR 0028 §1). The APIs it had to enable are recorded as gcportrait-api-NAME=enabled labels on the service and, with --build, on the build repository, which exists minutes earlier. A run that stops before either exists names the APIs it enabled. |
| What exists | before it creates anything, it checks that whatever exists of its names (the service, the backend account; with --build the build account, repository and bucket) carries its mark: the managed-by=gcportrait-deploy label, or on an account exactly the description deploy.sh writes |
deploy.sh never adopts or changes what it did not create. A resource of one of its names without the mark stops the run before anything is created, granted, uploaded or deployed; the message names each one, the command that hands it over if it is meant for this instance (deploy.sh then manages it, and uninstall.sh deletes it with the instance), and --service, which changes every derived name. |
| Backend account | SERVICE-backend@PROJECT.iam.gserviceaccount.com |
The service's identity, and the account a team grants in service-account mode. deploy.sh grants it no role at all, on nothing: not on this project, not on any organisation or folder. It reports any role it finds on the project, granted by someone else. |
Image (without --build) |
once per project and region, an Artifact Registry remote repository ghcr (Docker, upstream https://ghcr.io, labelled managed-by=gcportrait-deploy), shared by every instance in the region; the service runs REGION-docker.pkg.dev/PROJECT/ghcr/florianchrometz/gcportrait:TAG from it |
Cloud Run can run a public ghcr.io image directly, but Google recommends an Artifact Registry remote repository for availability (Cloud Run's "Deploying container images"): Artifact Registry fetches an image from ghcr.io the first time it is asked for it and keeps it, so a redeploy or a new instance does not depend on ghcr.io. It looks a tag up again once its cached answer is an hour old, so a tag that just moved (latest right after a release) can still give the image before for up to an hour; --image vX.Y.Z is exact. Public images need no credentials. Before it changes anything, deploy.sh asks ghcr.io anonymously whether the image exists, so an unknown tag or a package that is not public stops the run at once. An existing repository named ghcr is used only when it is a remote repository of ghcr.io; one that deploy.sh did not create is pulled through and never changed or deleted, and a repository of that name of any other kind stops the run. --image ghcr.io/OWNER/NAME:TAG (a fork's image) goes through the same repository. |
Build (--build) |
a Docker repository SERVICE-builds in the region (keeps the 5 newest images), a private regional bucket gs://PROJECT-SERVICE-builds (the uploaded source and the build logs, deleted after 14 days; created with its label in one request to the Cloud Storage API), a build account SERVICE-build@... with Storage Admin on that bucket and Artifact Registry Writer on that repository and nothing else, and one Cloud Build run of infra/cloudbuild.yaml |
A build account of its own instead of the project's Compute Engine default account, which would need the Compute Engine API and, in projects without the secure-by-default organisation policies, holds Editor on the whole project (ADR 0030). Storage Admin, on the one bucket only, is the role Google documents for a build account that keeps its logs in a bucket of the user's: Cloud Build reads the bucket itself before it accepts a build (with Storage Object Admin alone it refuses: FAILED_PRECONDITION: invalid bucket ... does not have access). On that bucket it also lets the build account change the bucket's IAM policy or delete the bucket; a narrower custom role (Storage Object Admin plus storage.buckets.get) is left open by the security review (security.md, O5). An earlier deploy.sh also granted Storage Object Admin there, which Storage Admin includes; a run finds and removes that grant. Bucket names are global, so an existing bucket of that name is used only when it belongs to this project and carries deploy.sh's label. The upload leaves out git-ignored files, legacy/, docs/, and anything local that may hold a credential (env files, *.local.* notes, keys: the same patterns as .dockerignore). |
| Cloud Run | the service SERVICE (default gcportrait) from the image, as the backend account: 1 CPU, 2 GiB (--memory), 80 concurrent requests, 0 to 10 instances, 900 s request timeout, startup CPU boost, startup and liveness probes on GET /healthz, labelled managed-by=gcportrait-deploy |
Scales to zero when nobody uses it. The environment it sets is in configuration.md: TRUST_PROXY_HOPS=1, LOG_LEVEL, SA_MODE_ENABLED, BACKEND_SA_EMAIL, and OAUTH_CLIENT_ID, HOSTED_BY, SOURCE_URL when given. |
| Access | public (allUsers may invoke the service), or with --iap Identity-Aware Proxy and the listed principals |
Public is safe in the sense of ADR 0035 §4: reading any Google Cloud data still needs the person's own sign-in and their own IAM roles. |
| Check | the newest revision is ready (its startup probe GET /healthz passed); without IAP it also waits for GET /api/healthz on the new address (up to seven tries, 5 s apart, while Google's front end still answers with its own page), then checks GET /api/config and GET /, and says in one line that the instance answers, with its version (--verbose shows each answer). An older image without /api/healthz answers it with the backend's own JSON 404, which ends the wait at once; GET /api/config is the check there |
Proves the instance answers before the script says so. Cloud Run's front end keeps paths ending in z for itself, so /healthz answers only inside the container (the probes, Docker); from outside it returns Google's 404, and /api/healthz gives the same answer. |
It never grants a role on an organisation, a folder or another project, never creates a service-account key, never stores a token or a secret (the OAuth client's secret is not used anywhere), and never uses or changes resources it did not create, except to read them (the row "What exists" above).
What Google adds by itself
Enabling the Cloud Run and Cloud Build APIs makes Google create default service accounts in the project, as
for any project that uses those products: the Compute Engine default account
PROJECT_NUMBER-compute@developer.gserviceaccount.com and the legacy Cloud Build account
PROJECT_NUMBER@cloudbuild.gserviceaccount.com, with their default roles, and it enables a few APIs those two
depend on (Container Registry, Pub/Sub, IAM Credentials). In an organisation that does not enforce
iam.automaticIamGrantsForDefaultServiceAccounts, the Compute Engine account gets Editor on the project
as soon as these APIs are enabled; deploy.sh warns when it finds that grant. gcportrait uses neither account.
If your organisation does not enforce that policy, consider removing that grant or enforcing the policy;
uninstall.sh leaves these accounts alone, because Google, not deploy.sh, created them.
Permissions to run deploy.sh
Owner of the project is enough. Without Owner, these roles on the project:
roles/serviceusage.serviceUsageAdmin (APIs), roles/iam.serviceAccountAdmin and
roles/iam.serviceAccountUser (create the accounts, deploy as them), roles/run.admin (the service and its
invoker policy), roles/iam.securityReviewer (read the project's policy for the report),
roles/artifactregistry.admin (the remote repository of released images, once per region, or with --build
the build repository), with --build also roles/storage.admin and roles/cloudbuild.builds.editor, with
--iap roles/iap.admin. deploy.sh also uses curl (macOS, Linux and Cloud Shell have it): to ask ghcr.io
whether a released image exists (without curl it skips that check), and with --build or --iap, where it is
required.
Uninstall in detail
infra/uninstall.sh --project YOUR_PROJECT --region europe-west3 # asks you to type the service name
It deletes exactly what deploy.sh created, found by its marks: the Cloud Run service with the
managed-by=gcportrait-deploy label (its invoker and IAP bindings go with it, and so do the domain
mappings that route to it), the build repository and bucket with the same label (images,
uploaded sources and logs included), and the backend and build accounts whose description deploy.sh wrote. The
remote repository ghcr of released images, with the same label, is shared by the instances of its region and
goes with the last one: uninstall.sh deletes it only when no other Cloud Run service or job of the project
deploys from it, and keeps it when it cannot list them. Anything of the same name without the mark is reported
and kept, for example an account you created yourself before the first deploy. --dry-run lists what it would
delete; --yes skips the question (needed without a terminal).
The APIs stay enabled unless you add --disable-apis: other workloads in the project may use them by now.
With it, uninstall.sh disables the APIs deploy.sh recorded as enabled by itself (on the service and on the
build repository), one by one, but first lists the project's Cloud Run services, jobs and worker pools in
every region and keeps what any other one still needs: run, iam, artifactregistry, cloudbuild and
storage while any other service, job or worker pool exists, iap while a service has IAP on, and every API
of an instance while another gcportrait instance (labelled managed-by=gcportrait-deploy) exists; it says
which workload keeps which API. When any of the three cannot be listed (an older gcloud without
run worker-pools, say), it keeps them all. An API that another enabled API depends on is kept too. Google's
check for recent use is skipped, and workloads outside Cloud Run are not looked at: GKE, Compute Engine (a VM
calling Cloud Asset Inventory, say), App Engine, first-generation Cloud Functions, and IAP in front of an
external load balancer (which leaves no mark on the services behind it). Those are yours to think of before
you pass --disable-apis. A run that stopped before either existed printed the APIs it enabled and the
gcloud services disable command for them; use that if you give up instead of running deploy.sh again.
After deleting the backend account, any grant made to it elsewhere (a team's roles/cloudasset.viewer) shows
as a deleted:serviceAccount:... member until its owner removes it. The OAuth client and its consent screen
are yours to delete in the console (Google Auth Platform › Clients).
Costs
Idle, the instance costs nothing but storage: Cloud Run scales to zero (min instances 0); without --build,
the remote repository keeps every released image it pulled (until uninstall.sh deletes it with the last
instance); with --build, Artifact Registry keeps up to five images and the build bucket holds the last 14
days of uploads and logs. In use, Cloud Run bills per request-second at 1 CPU and 2 GiB; a pull of a large
organisation streams for a few minutes, and a render through the API (api.md) keeps
the instance busy for a few seconds (--no-render-api turns it off, --memory lowers the memory with it).
Each --build is one Cloud Build run of a few minutes on an e2-highcpu-8 machine (--build-machine picks
another). Cloud Asset Inventory and Resource Manager calls are free of charge within their quotas. Check the
current prices on Google's pricing pages.
Logs
The backend writes JSON lines for Cloud Logging (pino; backend/README.md): for each request the path, with any
query string replaced by ?[redacted], and the request headers, of which only a short allowlist keeps its
values (every credential, cookie and identity header shows as [redacted]), with a request id. No request or
response body is logged. Cloud Run also keeps its own request log (run.googleapis.com/requests), which
records each request's full URL, query string included. The app never puts a token in a URL, and the backend
refuses a token sent that way with 401, but a client that sends one anyway leaves it in Google's request log;
keep access to the project's logs as narrow as access to the project. --log-level sets how much the backend
writes (Memory, instances and logs).
Troubleshooting
| Symptom | Cause and fix |
|---|---|
the Google Cloud CLI (gcloud) is not installed, or another tool |
The installer names the one that is missing; install the Google Cloud CLI, or use Cloud Shell, which has every tool. |
gcportrait has no release yet, so there is nothing to install |
The repository the installer reads publishes no release: build from a clone with --build (From a clone of the repository). |
could not download … or could not find the newest release |
GitHub did not answer, or the release named with --version does not exist. Check the connection and the tag (the repository's releases list them). |
the SHA-256 of the downloaded … is …, but checksums.txt … says … |
The archive is not the release's (a proxy that changes downloads, or a damaged file). Nothing was unpacked or run; try again on another connection. |
release … has no checksums.txt |
The release carries no checksums to check the deploy scripts against, so the installer runs nothing. Name another release with --version. |
gcloud has no active account |
gcloud auth login, then run the same line again. |
billing is not enabled |
Link a billing account to the project (console › Billing). |
ghcr.io/... has no public release yet |
The image's package refuses anonymous access: for a fork's image, make its package public, or build with --build. |
ghcr.io/... has no image TAG |
No release with that tag. Pick one from the repository's releases (--version vX.Y.Z with the installer). |
ghcr.io/OWNER/NAME does not exist or is not public |
A --image ghcr.io/... that Artifact Registry cannot pull without credentials. Check the name, or make the package public. |
the Artifact Registry repository ghcr in REGION is not a remote repository of https://ghcr.io |
A repository of that name already exists in the region for something else. Deploy in another --region, or with --build. |
| The deploy of a released image fails although ghcr.io has it, in a project inside a VPC Service Controls perimeter | Inside a perimeter, Artifact Registry denies remote repositories access to upstream sources outside it by default. Allow it for the region: gcloud artifacts vpcsc-config allow --project=YOUR_PROJECT --location=europe-west3. |
image ... not found or not readable |
An Artifact Registry image given in full with --image does not exist, or you may not read it. |
Cloud Build: FAILED_PRECONDITION: invalid bucket ...; service account ... does not have access to the bucket |
The build account cannot read its bucket. deploy.sh grants it Storage Admin on that bucket and repeats a refusal like this for about two minutes while the new grant propagates. If it persists, check the bucket's IAM policy (gcloud storage buckets get-iam-policy gs://YOUR_PROJECT-gcportrait-builds). |
Cloud Build: PERMISSION_DENIED: The caller does not have permission |
Also repeated for about two minutes. If it persists, check that you may act as SERVICE-build@... (roles/iam.serviceAccountUser) and start builds (roles/cloudbuild.builds.editor), and that no organisation policy restricts Cloud Build. |
https://.../healthz answers with Google's own 404 page |
Expected on Cloud Run: its front end keeps paths ending in z for itself. The probes reach /healthz inside the container; from outside, use /api/healthz, which answers the same. |
deploy.sh did not create the resources above, so it neither uses nor changes them |
A service, account, repository or bucket of one of deploy.sh's names exists without its mark (listed above the message, each with the command that hands it over). Deploy with another --service name, or hand over only what is meant for this instance: deploy.sh then manages it, and uninstall.sh deletes it with the instance. Another --service name does not rename the remote repository ghcr or free a domain: their lines say what to do (another --region or --build; the command that frees the domain). |
the bucket gs://PROJECT-SERVICE-builds exists but belongs to another project (or a 409 when it is created) |
Bucket names are global, and someone else has this one. Deploy with another --service name, which changes every derived name. |
| Cloud Build: quota exceeded for the machine type | --build-machine e2-standard-2 (slower), or ask for quota. |
the service is not public (allUsers is not an invoker) |
The organisation restricts IAM members to its domain (iam.allowedPolicyMemberDomains). Use --iap, or ask for a policy exception for the project. |
| The new revision does not become ready | gcloud run services logs read gcportrait --project=YOUR_PROJECT --region=europe-west3: an invalid variable stops the backend with a message naming it (never its value). |
Sign-in window shows origin_mismatch |
The address in the browser is not among the OAuth client's origins; see The OAuth client in detail. |
| deploy.sh stopped halfway | Run the same command again; every step checks what exists first. |
This page is docs/self-host-options.md in the repository, rendered for the website.