# [AI in Practice] Deploying Song Lingo to Cloud Run: Making a Private Lyrics Website Just for Me

> Source: <https://dev.to/evanlin/ai-in-practice-deploying-song-lingo-to-cloud-run-making-a-private-lyrics-website-just-for-me-mb3>
> Published: 2026-09-26 17:24:45+00:00

In [the previous post](https://dev.to/evanlin/ai-shi-zhan-gemini-38-flash-tts-zheng-shi-tui-chu-wo-yong-ta-zuo-liao-ge-gen-zhu-mv-xue-ri-wen-de-web-appran-hou-ba-tian-de-e-du-shao-guang-liao-2hmd-temp-slug-2564154), I used Gemini 3.8 Flash TTS to build Song Lingo: you paste a YouTube MV URL, Gemini transcribes the lyrics, adds furigana, translation, and grammar notes, and then a teacher designed via voice design reads it to you line by line.

It has only ever run on my own computer, but I want to be able to use it on my phone. So the goal of this post is simple: **Move it to Cloud Run so it can be used on mobile.**

However, this website has a unique characteristic: the page contains full lyrics and translations.

When a typical side project is deployed, it's at most a bit embarrassing if others see it. Song Lingo is different; making it public has two practical consequences:

So the goal isn't just to "have a login function," but to **ensure at every layer, from start to finish, that only my account can access the lyrics and APIs**.

I first compared two solutions:

|  | A. IAP + In-app Verification | B. No external access, use `gcloud run services proxy` | 
|---|---|---|
| Supported Devices | Any browser, including mobile | Only computers logged into `gcloud` | 
| Setup Difficulty | Medium | Low | 
| Potential for Error | Low | Lowest, no public entry point at all | 

Option B is the safest, but it doesn't work on mobile, which is the whole reason for this deployment. So I chose A.

My initial plan was this: store audio files in GCS, and when playing, the API generates a short-lived **signed URL** and redirects the browser to it. The advantage is that the audio doesn't pass through Cloud Run, saving bandwidth, and GCS itself supports Range requests.

Halfway through writing, while reconsidering "how to accurately ensure only I can see it," I realized this was a vulnerability:

**Within the expiration period, anyone who gets the signed URL can download it directly, completely bypassing IAP.**

It is essentially an anonymous bearer token. If the URL appears in browser history, is pasted somewhere, or is recorded by an extension, others can bypass all the previous login checks.

**Cause & Solution**: "Only I can access" is a chain; its strength depends on the weakest link. The final approach was **not to use signed URLs**. Audio files are always read by Cloud Run and then sent to the browser, and every playback must first pass IAP and in-app verification. An audio segment is about 250KB; the traffic cost of this extra hop is negligible.

| Component | Approach | 
|---|---|
| Container | One image containing both Node 22 and uv/Python; Next.js directly calls the original Python scripts | 
| Song Data & Audio | Private Cloud Storage bucket, **mounted as the `/data` folder** | 
| API key | Secret Manager, provided as environment variables | 
| Access Control | IAP + In-app verification of the IAP signature | 
| Instances | Max 1, min 0; CPU always allocated | 

Reasons for several decisions:

`output/` folder; after mounting, I just need to point `SONG_DATA_DIR` to `/data`, requiring almost no code changes.`--no-cpu-throttling`). "Adding a new song" continues transcription and analysis in the background after the response is sent. Cloud Run defaults to throttling the CPU after a response is sent, which would stall background tasks.
The final access control consists of four layers. If any single layer fails, the others still hold:

`--no-allow-unauthenticated`, only the IAP service account can call this service.` roles/iap.httpsResourceAccessor` can pass, which is only me.
The third layer might seem redundant since IAP is already in front. But it protects against "IAP layer misconfiguration": someone accidentally adding `--allow-unauthenticated`, IAP being turned off, or ingress settings being changed. If that happens, the third layer is the last line of defense.

Next.js 16 renamed `middleware` to `proxy.ts`, which runs in the Node.js runtime by default, making it perfect for this:

```
export async function proxy(request: NextRequest) {
  if (!process.env.K_SERVICE) return NextResponse.next();

  const result = await checkIapAssertion(request.headers.get("x-goog-iap-jwt-assertion"), {
    audience: process.env.IAP_AUDIENCE,
    allowedEmails: process.env.ALLOWED_EMAILS,
  });
  if (!result.ok) {
    console.warn(`[auth] rejected ${request.method} ${request.nextUrl.pathname}: ${result.reason}`);
    return new NextResponse(result.reason, { status: result.status });
  }
  return NextResponse.next();
}
```

Two design highlights:

`K_SERVICE` to determine if running on Cloud Run`IAP_AUDIENCE` or `ALLOWED_EMAILS` is not set, all requests return 500. A configuration error results in "the site won't open" rather than "the site is open to the public."
The verification itself follows [IAP documentation](https://cloud.google.com/iap/docs/signed-headers-howto): ES256 signature, issuer is `https://cloud.google.com/iap`, audience is `/projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_ID`, and the public key is fetched from Google's JWK endpoint.

Since there's no real IAP to hit locally, I made the key source a replaceable parameter and tested 12 scenarios locally using my own generated ES256 keys:

| Scenario | Result | 
|---|---|
| Allowed account (including case sensitivity) | Pass | 
| Other accounts, no email | 403 | 
| Wrong audience, wrong issuer, forged signature, expired, gibberish, missing header | 401 | 
| Missing audience, empty allowlist | 500 | 

Then I ran the production build in three modes:

| Mode | Result | 
|---|---|
| Local (no `K_SERVICE` ) | All 200 | 
| On Cloud Run, but forgot settings | All 500 | 
| On Cloud Run, settings complete, but missing or forged header | All 401 | 

Halfway through coding, two files I didn't create appeared in `git status`: `Dockerfile` and `.dockerignore`.

It turns out I accidentally had two Claude Code windows open. Both were discussing song-lingo, and the other one had also talked about deployment and already written a Dockerfile using a different approach: **mounting GCS as a folder**, while I was currently writing a whole storage abstraction layer to change all reads/writes to GCS API calls.

After comparing, the mounting approach was clearly better: it required almost no code changes, and the only advantage of my abstraction (version checking on write) wasn't really needed for a single-instance, single-user scenario. So I deleted my abstraction and switched to mounting.

But that wasn't all. After deploying, I found the service was already on **revision 2**. Revision 1 had been created earlier that day, and **IAP was already enabled**. That window hadn't just written a Dockerfile; it had actually deployed once.

**Cause & Solution**: Two agents in the same repo and same GCP project were working independently, unaware of each other. Nothing went wrong this time because I checked `git status` before acting and checked the revision list and service settings after deploying, rather than assuming "I am the first." My habit going forward: **Only do one task in one window**, and always check what's already in the cloud before starting a deployment.

After deploying and enabling IAP, I opened the URL and got a 502.

Initially, I thought the program crashed, but a key clue was in the response headers:

```
HTTP/2 502
x-goog-iap-generated-response: true

Empty Google Account OAuth client ID(s)/secret(s).
```

`x-goog-iap-generated-response: true` means **this error was returned by IAP itself**; the request never reached my code. The message indicates IAP has no OAuth client to use.

The reason is that my project **doesn't belong to any organization**; it was created with a personal Gmail account. IAP defaults to using a Google-managed OAuth client, which only supports accounts within an organization. For such projects, you must create your own OAuth client:

`https://iap.googleapis.com/v1/oauth/clientIds/CLIENT_ID:handleRedirect`.` gcloud iap settings set` to apply the client ID and secret to this service.
I executed step 3 in my own terminal rather than in the Claude Code chat, so the client secret wouldn't appear in any chat history.

My project contains many other services, and the OAuth consent screen was already set to public for other apps. My first reaction was, "If it's public, can anyone log in? Should I switch back to testing mode?"

The answer is **no need to change, and you shouldn't**:

| Layer | Responsibility | Impact of being Public | 
|---|---|---|
| OAuth Consent Screen | Confirm "Which Google account are you" | Any account can complete the login step | 
| IAP Access Permissions | Confirm "Can this account use this service" | Unaffected | 
| In-app Verification | Re-confirm signature and email | Unaffected | 

The consent screen is only responsible for "identifying who you are." The actual decision of "whether you can enter" is made by the subsequent two layers. Switching back to testing mode would instead affect other services sharing the same consent screen: only test users could log in, and authorization would expire in about 7 days.

**Cause & Solution**: When you see a 502, check the response headers first. `x-goog-iap-generated-response` tells you directly if the problem is with IAP or your code. Personal Gmail projects need their own OAuth client; whether the consent screen is public does not affect who can use the service.

`.env` and lyrics aren't uploaded
`gcloud run deploy --source .` uploads the entire folder to Cloud Build. The local `.env` has API keys, and `output/` has all the lyrics; these must absolutely not be uploaded.

`gcloud` defaults to using `.gitignore` if `.gcloudignore` is missing, and both of these were already in `.gitignore`. But "should be excluded" wasn't enough, so I explicitly wrote a `.gcloudignore` and used gcloud's own command to list what would actually be uploaded:

```
gcloud meta list-files-for-upload .
```

The result was 48 files; `.env`, `output/`, `node_modules`, and `.next` were all 0.

**Cause & Solution**: For commands that send files out, use the tool's own listing feature to see "what is actually being sent" rather than relying on your own inference of ignore rules.

`gcloud storage ls -r gs://bucket/**` resulted in 0. zsh tries to expand `**` as a local wildcard first; if it finds nothing, it errors out, and the query is never sent. Adding quotes fixed it: 116 objects, perfectly matching local.`--format` don't error`--format='value(iamConfiguration.publicAccessPrevention)'` gave empty output. The field name changed in newer gcloud versions, but it doesn't error; it just silently gives you a blank. Switching to JSON output revealed `public_access_prevention: enforced`.
The commonality here: **"0" and "blank" do not mean "none" or "no problem."** When verifying security settings, if you get an empty result, suspect the query itself first.

The post-deployment verification checklist, every item was actually run:

| Check | Result | 
|---|---|
| Open homepage, API, audio without logging in | IAP returns 302, redirects to Google login | 
| Attach a forged IAP signature header | Still 302, IAP doesn't accept external signatures | 
| Add a new song via POST without logging in | IAP returns 401 | 
| Anonymous access to bucket files and listing | 403 | 
| Cloud Run invocation permissions | Only IAP service account | 
| Bucket public permissions | No `allUsers` or`allAuthenticatedUsers` | 
| My account logs in and plays | Normal, in-app verification 0 rejections | 
| Other Google account logs in | "You don’t have access" | 

The last two can only be tested in a browser. Confirming "my account can use it" is actually the most critical: the audience format was filled according to documentation; if it were wrong, I would pass IAP but be blocked by my own code. I confirmed this by searching Cloud Run logs for `[auth] rejected`, which returned 0.

I also saw something in the logs that made me very happy: I **added a new song directly in the cloud**, and the entire workflow—transcription, analysis, demo audio generation, and playing from the bucket—worked perfectly within the container.

| Item | Estimate | 
|---|---|
| Cloud Run | Scales to 0 when not in use, no charge; CPU always allocated only charges during instance uptime | 
| Cloud Storage | ~27MB, less than US$0.01 per month | 
| Cloud Build | A few minutes per deployment, within free tier | 
| Gemini API | Same as local, billed by usage | 

There are two more things I need to do in the Console:

Also note: the bucket and local `output/` are now two independent sets of data. Songs added in the cloud won't automatically sync back; use `gcloud storage rsync` when needed.

**"Only I can access" is a chain, not a door.** No matter how well IAP is set up, a single signed URL can bypass it. When checking access control, list all paths through which data can exit, not just the entrance.

**When a setting is missing, the system should fail closed, not open.** In-app verification rejects everything if settings are missing. If a future deployment misses an environment variable, the result will be that I can't open the site and will notice immediately, rather than the site silently being open to the public for weeks.

**Look at who returned the error.** For a 502, the `x-goog-iap-generated-response` header narrows the problem down from "the whole service" to "IAP OAuth configuration."

**Suspect the query first for empty results.** An object count of 0 or a blank configuration field might be a malformed query rather than the resource not existing. In security verification, the cost of such a misjudgment is particularly high.

**Do one task in only one agent window.** Two Claude Code windows acting on the same project simultaneously were only prevented from overwriting each other because I checked the current state before acting.

**Keep secrets out of the conversation.** OAuth client secrets and API keys were handled in my own terminal. The AI was only responsible for giving me the commands, ensuring secrets never entered the chat history.

The code is at [kkdai/song-lingo](https://github.com/kkdai/song-lingo). The deployment section of the README has complete commands and a verification checklist (values replaced with placeholders). Related documentation: [IAP for Cloud Run](https://cloud.google.com/run/docs/securing/identity-aware-proxy-cloud-run), [Verifying IAP assertion headers](https://cloud.google.com/iap/docs/signed-headers-howto), [IAP custom OAuth configuration](https://cloud.google.com/iap/docs/custom-oauth-configuration).
