Deploy a website with Cloud Run — a two-persona developer-experience study
Firsthand execution · 26 July 2026
The Google Codelab "Deploy a website with Cloud Run" (last updated 20 March 2026) was executed end to end, twice, by two deliberately different personas, against production Google Cloud. Nine steps, two fresh projects, every command run as printed.
1. Executive summary
Both personas finished. Neither finished the way the codelab says they will.
| Persona A — new to Google Cloud | Persona B — experienced | |
|---|---|---|
| Environment | Cloud Shell, as instructed | local gcloud |
| Steps completed | 9 / 9 | 9 / 9 |
| Time | 30m 26s (40m 07s raw) | 6m 04s |
| Blocks | 1, needed outside help | 4, all self-recovered |
| Concepts to absorb | 56 | ~5 |
| Believed remaining spend | "a handful of MB" | 356 MB + orphans |
The central finding is not that the codelab is hard. It is that the codelab is accurate about everything except what happens when something goes wrong — and something goes wrong for both personas, at the same step, for the same reason.
Three results are worth a product owner's attention:
- A new project cannot reliably run step 4. Both personas hit
PERMISSION_DENIEDon their first Cloud Build. A controlled probe proved the cause is IAM propagation delay, not a missing role — and the error message actively prescribes the wrong fix. - The cleanup step does not clean up. It reports success while leaving 356 MB billing in Artifact Registry. Persona A finished believing it had left behind "a handful of MB of tarballs."
- The newcomer got the worse error message. For the identical failure, Cloud Shell returned
The caller does not have permission.while localgcloudnamed the permission, the principal, the resource, and supplied a troubleshooter URL.
Point 3 is the one that generalises. The person with the least context received the least information.
2. Method
Two personas, two isolated projects, one codelab.
Persona A was executed by a separate, smaller model in its own context under a written clean-room protocol: act only on what the page says; escape a failure only by re-reading the step, reading the literal error, and searching the exact error string; log every ambiguity before resolving it; hard cap ~10 minutes per blocker, then stop and request help. It logged a wall-clock timestamp at every step boundary.
Persona B was executed firsthand by the analyst, locally, with full documentation access — because that is what an experienced developer actually does. Persona B ran each command as printed first, then recovered using expert knowledge, so both personas are measured against identical starting commands.
Isolation was by project (cl-dx-a-0726, cl-dx-b-0726), created fresh. Without this, one persona enabling an API silently erases the other's step.
Measurement is two-axis: measured wall-clock seconds against a count of concepts the step requires the persona to hold. The concept inventory was built from the codelab text before either run, so the counts cannot have been shaped by knowing what broke.
Limits, stated up front
- Persona A is a smaller model, not a human. This is a real improvement over one operator narrating both sides, and it is not a substitute for a study with human participants. Its timings are measured, but they are measurements of a model following a protocol.
- Sample of one run per persona, two projects, one account, one afternoon, no Organization. Enterprise IAM would change several findings.
- ~10 minutes of Persona A's raw time was spent on a defect in this study's own capture tooling, not on the codelab. That time is excluded from the adjusted figure and the exclusion is documented in
scoring.md. - One pre-registered hypothesis was wrong. Before running, this study predicted that
gcr.io(Container Registry) would be dead and--platform managedstale. Both still work. Recorded as a miss.
3. The two-axis result
Time cost against cognitive load, per step, per persona. Thresholds stated: high time >120s, high load >4 concepts.
Two structural results fall out of the data:
Every Persona B cell is low-load. The codelab asks an experienced developer to understand nothing they do not already understand. Their entire cost is time.
Persona A has no clean cells after step 1. Seven of nine steps are high-load and high-time.
And the sharpest single cell is step 1 — the "Before you begin" page, which asks the reader to do nothing at all and delivers 12 undefined concepts, the largest block in the codelab. It is cleared in 25 seconds and the vocabulary debt is carried into everything that follows. It reads as a friendly preamble and functions as the steepest cliff in the document.
Concept coverage
Of 56 distinct concepts the codelab introduces, exactly one is defined at first appearance (Project ID). Four more are partially explained. 51 are used without explanation — a definition coverage of 8.9%.
That is not automatically a defect: a codelab may reasonably assume prerequisites. But the codelab states its prerequisite as "General familiarity with Docker" and nothing else. Under its own stated contract, Knative, Cloud Build, Container Registry, revisions, concurrency, IAM service accounts, regions, and gcr.io are all in scope and all undefined.
4. Finding 1 — A new project cannot reliably run step 4
Both personas failed their first gcloud builds submit. The obvious conclusion is that the codelab omits an IAM step. That conclusion is wrong, and this study nearly published it.
The tell: the failing service account already held roles/editor, which already grants the exact permission the error named.
A controlled probe settled it. A third, independent project was created and the same operation attempted immediately:
| Time | Event |
|---|---|
| 18:46:52 | project created |
| 18:47:35 | attempt 1 — FAILED, iam.serviceAccounts.get denied |
| — | no IAM changes whatsoever |
| 18:52:20 | attempt 2 — SUCCESS, 24s build |
The service account still held exactly one role, roles/editor, unchanged. And the two projects failed on different permissions — storage.objects.get on one, iam.serviceAccounts.get on the other. A missing role fails identically every time; eventual consistency does not.
A new Google Cloud project has a window of roughly 3–5 minutes after creation during which Cloud Build fails with permission errors naming permissions the account demonstrably already has.
Why this matters more than the failure
The error message prescribes the wrong action. It names a permission, names a principal, and supplies an IAM Troubleshooter URL. Every signal points at editing IAM policy. The correct action is to wait sixty seconds.
- Persona A took it at face value, could not fix IAM, and stopped. Under the protocol it declared BLOCKED after 108 seconds. Its own search correctly surfaced propagation delay as a possibility — and a newcomer has no way to tell which of several plausible causes is real.
- Persona B knew how to grant roles, granted three, saw the next attempt succeed, and drew a false causal conclusion. That is how a cargo-cult step enters a runbook and propagates.
The second failure mode is worse because it is silent and it scales. The analyst made exactly this error and caught it only because a pre-existing roles/editor binding happened to be visible in the output.
The asymmetry in the error text
Same underlying condition, two environments:
Cloud Shell (Persona A): PERMISSION_DENIED: The caller does not have permission.
local gcloud (Persona B): Error 403: 131568237952-compute@developer.gserviceaccount.com
does not have storage.objects.get access to ...
buckets/cl-dx-b-0726_cloudbuild/objects/source/...tgz
Remediate access with this Troubleshooter URL - https://...
The newcomer, in the environment the codelab prescribes, received no permission name, no principal, no resource, and no troubleshooter link.
Recommendation R1. Retry transient permission failures automatically in gcloud builds submit on projects younger than ~10 minutes, or add a first-class error: "This project was created recently. Permissions may still be propagating — wait 60s and retry." The information needed to produce that message (project creation time) is already available to the caller.
Recommendation R2. Bring Cloud Shell's error verbosity to parity with local gcloud. The current asymmetry inverts the relationship between need and information.
5. Finding 2 — Clean up does not clean up
Persona B ran all four cleanup commands exactly as printed. All four reported success. The post-cleanup audit:
| Resource | State after "successful" cleanup |
|---|---|
| Cloud Run service | deleted — correct |
| Container image tags | deleted — correct |
Artifact Registry gcr.io repo |
356.162 MB still stored |
| Orphaned source tarball | 1.5 MB, unreachable by the cleanup |
| Enabled APIs | 29, none disabled |
| Project | never mentioned |
The Artifact Registry figure is not stale metadata: the repository's updateTime (18:50:24) is after both image deletions. Deleting the two tags did not reclaim the underlying layers.
The orphaned tarball is a compounding defect. Cleanup command 3 derives its list from gcloud builds list — but the failed submit at 18:44:46 uploaded a tarball without ever creating a build record. It is therefore invisible to the cleanup and remains permanently. Every failed build leaves an unreachable object behind, and this codelab reliably produces at least one failed build (Finding 1).
The cleanup command fails in the environment the codelab prescribes
For Persona B, on a wide local terminal, cleanup command 3 worked and deleted two objects.
For Persona A, in Cloud Shell, the identical command failed 11 times and deleted nothing:
CommandException: The rm command (without -I) expects at least one URL.
Persona A diagnosed it using only on-page resources — the codelab's own commented-out diagnostic line — and found the cause: in Cloud Shell's narrow terminal pane, gcloud builds list renders one line per field (a vertical KEY: value block) rather than one line per build. The command's awk 'NR > 1 {print $4}' extracts column 4 of a table that isn't there, yielding empty strings.
The codelab's cleanup one-liner is terminal-width dependent. It fails in Cloud Shell — the environment the codelab instructs you to use — and works locally, which it never mentions.
What the newcomer believed
Persona A's log, written before any audit, records its honest belief about remaining spend:
"some small ongoing Cloud Storage charge (a handful of MB of tarballs from two builds) is the only likely leftover"
Actual: 356 MB in Artifact Registry, an unreachable orphan, and 29 enabled APIs. The mental model is wrong by roughly two orders of magnitude, and it is wrong in the direction that costs money.
Recommendation R3. Replace the awk pipeline with a format-stable command (gcloud builds list --format="value(source.storageSource.bucket,source.storageSource.object)"). Text-table scraping is fragile by construction and this instance breaks in the prescribed environment.
Recommendation R4. Add Artifact Registry repository deletion to the cleanup step, and state the true end state. The step's implicit contract is "you will not be charged." It does not deliver that.
Recommendation R5. Recommend deleting the project as the terminal cleanup action. It is the only operation that reliably reclaims everything, and it is the one thing the step never mentions.
6. Finding 3 — The codelab is written for Cloud Shell and silently punishes anyone else
The codelab's commands interpolate ${GOOGLE_CLOUD_PROJECT}, an environment variable injected by Cloud Shell that does not exist on a local machine. Verified locally: it is empty.
gcr.io/${GOOGLE_CLOUD_PROJECT}/monolith:1.0.0 collapses to gcr.io//monolith:1.0.0. The shell does not warn. The failure surfaces much later, from a different system, in this form:
Permission "artifactregistry.repositories.downloadArtifacts" denied on resource
"projects/monolith/locations/us/repositories/gcr.io" (or it may not exist)
The collapsed path parses monolith as the project name. So the error blames your account for lacking permission on a project you never created, never mentions the variable that caused it, and hedges with "or it may not exist." There is no path from this message to the actual cause.
The same Cloud-Shell assumption breaks the repo's own setup script. setup.sh branches on if [ -z "$CLOUD_SHELL" ] — it explicitly intends to support local execution — and then hardcodes Cloud Shell's own NVM location:
export NVM_DIR="/usr/local/nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install --lts
Off Cloud Shell the guarded source is skipped, nvm is never defined, the unguarded call fails, and set -eEo pipefail aborts the script before a single dependency installs. The local path is broken by construction: the script tests for the non-Cloud-Shell case and then does something only Cloud Shell can satisfy.
Recommendation R6. Replace ${GOOGLE_CLOUD_PROJECT} with $(gcloud config get-value project), which is correct in both environments and self-documenting.
Recommendation R7. Either fix setup.sh's local branch or delete it and state that Cloud Shell is required. A branch that advertises support it does not deliver is worse than no branch.
7. Finding 4 — Documentation drift is visible on the page
Captured from the live page on 26 July 2026, from a document dated 20 March 2026:
| What the page shows | What actually happens |
|---|---|
| Region prompt listing 5 regions | 44 flat alphabetical entries, no marked default, no geo hint |
| Prompt order: region, then service name | Service name asked first, then region |
gcloud run services list as a single-line table |
vertical KEY: value block in Cloud Shell |
| "Select [1] Cloud Run (fully managed)" | no platform prompt appears at all |
Expected output timestamped 2019-09-16 |
— |
"It may take a few moments for the Pod status to be Running" |
Cloud Run fully managed has no Pods — Kubernetes vocabulary left over from Anthos |
**Note:**You can also view... |
markdown bold markers render literally, no space after colon |
The region drift is the one that costs real time. The page says "select the region closest to you." The actual prompt offers 44 alphabetically-ordered entries with no default, no latency hint, and no geolocation. Persona A, having no basis to choose, fell back on matching the region family the codelab itself had used in an earlier step — a reasonable inference the page never invites.
This is worth noting precisely because concept-count scores it as zero load: "region" was already introduced. The metric cannot see a decision that introduces no new noun. It is carried here as a qualitative finding instead.
Recommendation R8. Mark a default region in the prompt, or order by measured latency from the caller. A 44-item alphabetical list is not a choice architecture.
Recommendation R9. Re-capture the codelab's terminal examples. Several show 2019-era output. The Pod status sentence is actively misleading for the product being taught.
8. What the codelab does well
Stated with equal weight, because a study that only finds faults is not measuring, it is prosecuting.
- Zero-downtime deployment genuinely works and is genuinely impressive. Persona B's step 8 took 9 seconds, served HTTP 200 throughout, and retained all three revisions. The codelab teaches a real platform strength honestly.
gcloud run deployself-heals a gap the codelab leaves. The Cloud Run API is never enabled by any instruction. The deploy command detects this and enables it automatically. That is exactly the right behaviour and it silently rescued both personas.- The time estimate is honest. The page advertises "39 mins remaining." Persona A took 30m adjusted, 40m raw. Many tutorials' estimates are fiction; this one is calibrated for its actual audience.
- The app builds cleanly on a modern toolchain. A 2019-era React app compiled without incident on Node 26 — no legacy-OpenSSL failure, which was a plausible risk.
- Concurrency and revisions are taught well. Step 6 is the fastest step in the study for both personas and demonstrates a genuinely differentiated Cloud Run capability in one flag.
- The cleanup step at least exists, names three resource categories, and includes a note acknowledging that other Cloud Build artifacts need manual removal. It is incomplete, not absent — most tutorials do worse.
9. Persona-specific reading
Persona A — unfamiliar with Google Cloud
The codelab works for you, slowly, until it doesn't — and when it doesn't, you have no instruments. Your hardest moments are not the conceptually hard steps; they are the steps where the product tells you something untrue about why it failed. You will finish in roughly the advertised time if nothing goes wrong, and you will stop dead at step 4 if it does, because the error tells you to fix a permission you cannot fix and that was never the problem.
Your worst outcome is not failure. It is finishing successfully with a wrong model of what you are being billed for.
Persona B — experienced with Google Cloud
The codelab costs you nothing to understand and a surprising amount to execute — 6 minutes, but four separate blocks in those 6 minutes, none of which the page warns about. Every one is caused by the codelab assuming Cloud Shell while you work locally.
Your specific risk is the opposite of Persona A's: you are fast enough to recover from all four blocks without ever diagnosing them correctly. This study's own analyst "fixed" the IAM failure with three role grants that were almost certainly irrelevant, and would have published that as a finding.
10. Recommendations, consolidated
| # | Recommendation | Owner | Effort |
|---|---|---|---|
| R1 | Auto-retry or explain transient permission failures on new projects | Cloud Build / gcloud | Medium |
| R2 | Bring Cloud Shell error verbosity to parity with local gcloud | Cloud Shell | Medium |
| R3 | Replace the awk-scraping cleanup with --format="value(...)" |
Codelab | Trivial |
| R4 | Add Artifact Registry deletion to cleanup; state the true end state | Codelab | Trivial |
| R5 | Recommend project deletion as terminal cleanup | Codelab | Trivial |
| R6 | Replace ${GOOGLE_CLOUD_PROJECT} with $(gcloud config get-value project) |
Codelab | Trivial |
| R7 | Fix or remove setup.sh's local branch |
Sample repo | Low |
| R8 | Mark a default region, or order by latency | gcloud | Medium |
| R9 | Re-capture 2019-era terminal examples; remove Pod status |
Codelab | Low |
Five of nine are trivial edits to the codelab text. The two that require product work — R1 and R2 — are also the two that decide whether a newcomer finishes or abandons.
11. Open questions
- Is the propagation window bounded? The probe waited ~5 minutes and did not bisect. The 3–5 minute figure is bounded by observation, not measured.
- Does this reproduce under an Organization? Both projects were individual-account, no org policies. Enterprise IAM could change Finding 1 materially.
- Is the Cloud Shell error asymmetry deliberate? It could be an intentional verbosity setting rather than an oversight. Worth confirming before filing.
- How many other codelabs use the same awk-scraping cleanup? If the pattern is copied across the codelab corpus, R3 is a corpus-wide fix rather than a single edit.
- Would a human newcomer stop at step 4? Persona A stopped because its protocol told it to. A human might grind, might search more effectively, or might quit entirely. This is the question only a human study answers.
Environment
| Date | 2026-07-26 |
| Codelab | "Deploy a website with Cloud Run", last updated 2026-03-20 |
| CLI | Google Cloud SDK 577.0.0 |
| Account | individual Google account, no Organization |
| Projects | cl-dx-a-0726, cl-dx-b-0726, cl-dx-probe-0726 — all created for this study |
| Node | v26.0.0, npm 12.0.1 |