<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: h6o</title>
    <description>The latest articles on DEV Community by h6o (@newbee1939).</description>
    <link>https://dev.to/newbee1939</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2614752%2F849d325f-b04f-4468-90b7-4a0eededb872.jpg</url>
      <title>DEV Community: h6o</title>
      <link>https://dev.to/newbee1939</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/newbee1939"/>
    <language>en</language>
    <item>
      <title>When You Use a PGA Subnet on Cloud Run, Traffic to Google APIs Is Treated as Internal</title>
      <dc:creator>h6o</dc:creator>
      <pubDate>Wed, 10 Jun 2026 00:00:21 +0000</pubDate>
      <link>https://dev.to/newbee1939/when-you-use-a-pga-subnet-on-cloud-run-traffic-to-google-apis-is-treated-as-internal-48ag</link>
      <guid>https://dev.to/newbee1939/when-you-use-a-pga-subnet-on-cloud-run-traffic-to-google-apis-is-treated-as-internal-48ag</guid>
      <description>&lt;p&gt;At work, I tried to put a source-IP restriction on a certain Google API key and ran into a phenomenon where the setting just wouldn't take effect no matter what I did. Tracing the cause led me to Cloud Run's network path — specifically, the behavior of Private Google Access (PGA).&lt;/p&gt;

&lt;p&gt;Since other people are likely to hit the same thing, I'm writing this down as a memo for myself. I hope it helps anyone in a similar situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;What I wanted to do was put a source-IP restriction on a Google API key, to limit the damage in case of a leak. I figured "I'll just allow-list Cloud Run's egress IP," but the setting wouldn't block anything. Cloud NAT's static IP was on the allow list, and yet for some reason it didn't take effect.&lt;/p&gt;

&lt;p&gt;Cutting to the conclusion: &lt;strong&gt;on a subnet with PGA enabled, traffic destined for Google APIs was not going through Cloud NAT&lt;/strong&gt; — that was the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Private Google Access (PGA)?
&lt;/h2&gt;

&lt;p&gt;PGA (Private Google Access) is a mechanism that lets VMs and services that don't have an external IP reach Google APIs like &lt;code&gt;*.googleapis.com&lt;/code&gt; directly through an internal path within the VPC. The benefit is that you can reach Google services without going out to the internet, and it's enabled per subnet.&lt;/p&gt;

&lt;p&gt;In Terraform it's enabled with a single line on the subnet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_subnetwork"&lt;/span&gt; &lt;span class="s2"&gt;"egress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sb-egress-example"&lt;/span&gt;
  &lt;span class="nx"&gt;private_ip_google_access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# ← this is PGA&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Cloud Run attaches to this subnet via Direct VPC Egress.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;run.googleapis.com/network-interfaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[{"network":"...","subnetwork":".../sb-egress-example"}]'&lt;/span&gt;
    &lt;span class="na"&gt;run.googleapis.com/vpc-access-egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all-traffic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vpc-access-egress: all-traffic&lt;/code&gt; is the setting that routes all egress through the VPC. So far this looks like a very common configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traffic to Google APIs does not go through NAT
&lt;/h2&gt;

&lt;p&gt;The important point is the path that Google API-bound traffic takes on a PGA-enabled subnet. As a diagram, it branches like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[When PGA is enabled]

Cloud Run ──┬─ *.googleapis.com bound ──▶ goes directly to Google via the internal network (PGA)
            │                              * does not go through Cloud NAT
            │                              * source is an internal IP
            │
            └─ Other internet-bound traffic ──▶ Cloud NAT ──▶ NAT's static external IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, this is what was happening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;General internet-bound traffic goes through Cloud NAT, so the source becomes NAT's static external IP&lt;/li&gt;
&lt;li&gt;However, &lt;code&gt;*.googleapis.com&lt;/code&gt;-bound traffic takes the PGA internal path, so it does not go through NAT&lt;/li&gt;
&lt;li&gt;As a result, the source as seen from the Google API side is not the NAT external IP but an internal IP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;API-key IP restrictions are something that, by their nature, only accept public IPs. So for PGA-routed requests coming in with an internal IP, there was simply no way for an IP restriction to apply. "Can't pin it to an external IP" — this is what that meant.&lt;/p&gt;

&lt;p&gt;This behavior is also explicitly stated in the official Cloud NAT documentation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: Traffic sent to Google APIs and services are routed through Private Google Access even if the VM instance initiating the connections uses Public NAT. For more information, see Private Google Access interaction.&lt;/p&gt;

&lt;p&gt;― &lt;a href="https://docs.cloud.google.com/nat/docs/overview" rel="noopener noreferrer"&gt;Cloud NAT overview&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So it's clearly written as the specification that "even if you're using Public NAT, traffic to Google APIs alone flows via PGA."&lt;/p&gt;

&lt;p&gt;For reference, the Cloud NAT side is configured to allocate a static external IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_router_nat"&lt;/span&gt; &lt;span class="s2"&gt;"nat"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nat-example"&lt;/span&gt;
  &lt;span class="nx"&gt;nat_ip_allocate_option&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MANUAL_ONLY"&lt;/span&gt;   &lt;span class="c1"&gt;# manually allocate a static IP&lt;/span&gt;
  &lt;span class="nx"&gt;nat_ips&lt;/span&gt;                             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;google_compute_address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;self_link&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;source_subnetwork_ip_ranges_to_nat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ALL_SUBNETWORKS_ALL_IP_RANGES"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If traffic egresses via NAT, the source becomes this static IP — but the pitfall is that as long as PGA is enabled, Google API-bound traffic alone does not go through this NAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Countermeasure: disable PGA if you want IP restrictions to take effect
&lt;/h2&gt;

&lt;p&gt;If you want to pin the source IP toward Google APIs so that API-key IP restrictions take effect, disable PGA on that subnet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_subnetwork"&lt;/span&gt; &lt;span class="s2"&gt;"egress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;private_ip_google_access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# disable PGA&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With PGA disabled, traffic to Google APIs also goes through Cloud NAT and out to Google from the internet side. The source becomes NAT's static external IP, so finally — in principle — the API-key IP restriction starts working.&lt;/p&gt;

&lt;p&gt;You can confirm that the path has switched to NAT by checking Cloud NAT's flow logs for whether &lt;code&gt;*.googleapis.com&lt;/code&gt;-bound entries (Google's ASN 15169) appear. When PGA is enabled, Google-bound traffic does not appear here (because it doesn't go through NAT).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to choose
&lt;/h2&gt;

&lt;p&gt;Summarized, the differences are as follows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;PGA enabled&lt;/th&gt;
&lt;th&gt;PGA disabled (via NAT)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reach to Google APIs&lt;/td&gt;
&lt;td&gt;Directly via internal nw&lt;/td&gt;
&lt;td&gt;Via the internet (NAT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source IP&lt;/td&gt;
&lt;td&gt;Internal IP (not static)&lt;/td&gt;
&lt;td&gt;NAT's static external IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-key IP restriction&lt;/td&gt;
&lt;td&gt;Does not work&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exposure to the internet&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Egresses via NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Looking purely from a security angle, PGA — which "doesn't go to the internet" — looks preferable. But if you want to apply a separate guard like API-key IP restriction, you may have to deliberately choose to go via NAT. That was the lesson this time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;When you can't pin the source IP for Google API-bound traffic on Cloud Run, a good first thing to suspect is whether PGA is enabled or disabled. I hope this helps anyone stuck in the same place.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>cloudrun</category>
      <category>networking</category>
      <category>vpc</category>
    </item>
    <item>
      <title>Securely Exposing a Stateful MCP Server on Cloud Run (n8n Playwright MCP Example)</title>
      <dc:creator>h6o</dc:creator>
      <pubDate>Tue, 09 Jun 2026 23:52:58 +0000</pubDate>
      <link>https://dev.to/newbee1939/securely-exposing-a-stateful-mcp-server-on-cloud-run-n8n-x-playwright-mcp-example-1n5k</link>
      <guid>https://dev.to/newbee1939/securely-exposing-a-stateful-mcp-server-on-cloud-run-n8n-x-playwright-mcp-example-1n5k</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I wanted to operate pages that require Google login from n8n via Playwright MCP&lt;/li&gt;
&lt;li&gt;The sidecar approach is easy, but has gaps from the perspectives of authentication and team isolation&lt;/li&gt;
&lt;li&gt;I built defense-in-depth with &lt;strong&gt;&lt;code&gt;ingress: internal&lt;/code&gt; + IAM (&lt;code&gt;roles/run.invoker&lt;/code&gt;) + service-to-service auth via ID tokens + a Go auth-proxy + Secret Manager&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;For stateful MCP, set &lt;code&gt;maxScale=1&lt;/code&gt; to stop scale-out and prevent sessions from jumping to another instance&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Intended Audience
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;People who want to run an MCP server on Cloud Run&lt;/li&gt;
&lt;li&gt;People who want to automate operations on pages that require Google login using Playwright MCP&lt;/li&gt;
&lt;li&gt;People who share n8n across multiple teams and want to handle pages requiring per-team Google logins via Playwright MCP&lt;/li&gt;
&lt;li&gt;People who want to set up Cloud Run service-to-service authentication (ID tokens + IAM) in a practical way&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;The starting point was: I wanted to operate and capture &lt;strong&gt;pages that require Google login&lt;/strong&gt;, like Looker Studio, from n8n workflows. Playwright MCP looked like it could make this work, so I tried it. But once I tried to put it into operation, I ran into the following challenges.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Since n8n is shared across multiple teams, I want to switch login states per team account&lt;/li&gt;
&lt;li&gt;I don’t want a Playwright MCP endpoint that "anyone can hit" in the first place&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Problem: Gaps in the Sidecar Setup
&lt;/h2&gt;

&lt;p&gt;The first thing that comes to mind is running playwright-mcp as a sidecar in the same Cloud Run instance as n8n. It's easy, but it has gaps with respect to the challenges above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Before: dangerous setup]
┌─ Cloud Run instance ─────────────────────────┐
│  n8n (port 5678)                             │
│        │ localhost:3000 (no auth required)   │
│        ▼                                     │
│  playwright-mcp (port 3000)                  │
│        ※ holds Google-logged-in session      │
└──────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since it's a sidecar, playwright-mcp isn't visible from outside. However, n8n inside the same instance can hit &lt;code&gt;localhost:3000&lt;/code&gt; without any authentication.&lt;/p&gt;

&lt;p&gt;Because playwright-mcp is holding the Google-logged-in state (Storage State):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Anyone who can build n8n workflows can use the shared login credentials&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;It can't accommodate use cases where each team needs a different account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the result. Removing the sidecar and splitting it into a separate Cloud Run service decouples them, but just splitting it leaves "should it be exposed to the internet or only inside the VPC?" and "how do we authenticate?" up in the air.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solution Architecture
&lt;/h2&gt;

&lt;p&gt;In the end, I adopted the following setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[After: defense-in-depth setup]

n8n (Cloud Run, ingress=internal)
 │  Mcp-Auth-Key: &amp;lt;per-team API key&amp;gt;
 │  Path: /playwright-mcp-team-a/...
 │
 │  ※ n8n has vpc-access-egress=all-traffic, so traffic
 │    is routed to internal Cloud Run via the VPC
 ▼
auth-proxy (Cloud Run, ingress=internal)
 │  - Verifies Mcp-Auth-Key
 │  - Picks the backend by the first URL segment
 │  - Attaches its own service account's ID token and forwards
 ▼
playwright-mcp-team-a (Cloud Run, ingress=internal, maxScale=1)
   - IAM: roles/run.invoker is granted only to the auth-proxy SA
   - On receipt, Cloud Run verifies the ID token
   - Storage State is mounted via Secret Manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 4 defensive layers. They are &lt;strong&gt;gates stacked in series&lt;/strong&gt;, and if any one of them is breached, the meaning of the remaining layers weakens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Block direct access from outside with &lt;code&gt;ingress: internal&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;Grant &lt;code&gt;roles/run.invoker&lt;/code&gt; only to the auth-proxy SA, excluding other principals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-to-svc auth&lt;/td&gt;
&lt;td&gt;The auth-proxy presents a &lt;strong&gt;Google-signed ID token&lt;/strong&gt; in the Authorization header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;The auth-proxy verifies the &lt;code&gt;Mcp-Auth-Key&lt;/code&gt; and routes to the backend for each team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'll cover how the ID token and IAM mesh together in detail in the service-to-service authentication section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting Google Login State: storage-state
&lt;/h2&gt;

&lt;p&gt;Playwright has a &lt;code&gt;--storage-state&lt;/code&gt; option that lets you save and reuse logged-in session info (cookies and localStorage) as a file. Storing this in Secret Manager and mounting it as a Cloud Run volume lets you keep the login state even after a cold start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-storage-state&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PLAYWRIGHT_STORAGE_STATE&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage-state.json&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-mcp&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--storage-state=/etc/playwright/storage-state.json"&lt;/span&gt;
    &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-storage-state&lt;/span&gt;
        &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/playwright&lt;/span&gt;
        &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you want to update the login state, just log in again on another machine and register the new &lt;code&gt;storage-state.json&lt;/code&gt; as a new version in Secret Manager. Restarting the service will pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing mcp-auth-proxy (Go)
&lt;/h2&gt;

&lt;p&gt;I implemented a lightweight service handling authentication and reverse proxying in Go. The reverse-proxy foundation is just the standard library's &lt;code&gt;net/http/httputil.ReverseProxy&lt;/code&gt;, and I only add &lt;code&gt;google.golang.org/api/idtoken&lt;/code&gt; for getting ID tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend List
&lt;/h3&gt;

&lt;p&gt;This is the only place you touch when adding a new team.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;routeSpecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;PathID&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;APIKeyEnv&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;BackendURLEnv&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PathID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"playwright-mcp-team-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;APIKeyEnv&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;"TEAM_A_PLAYWRIGHT_MCP_KEY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;BackendURLEnv&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"TEAM_A_PLAYWRIGHT_MCP_URL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c"&gt;// To add team B, add one element here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;

&lt;p&gt;It's a simple mechanism that just compares the value of the &lt;code&gt;Mcp-Auth-Key&lt;/code&gt; header to an environment variable's key. &lt;code&gt;subtle.ConstantTimeCompare&lt;/code&gt; is used to avoid timing attacks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Mcp-Auth-Key"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;routes&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;providedKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;providedKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Pick the backend by the first segment of the URL&lt;/span&gt;
    &lt;span class="n"&gt;routeID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IndexByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;routeID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subtle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConstantTimeCompare&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providedKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;routeID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How Cloud Run Service-to-Service Authentication Works
&lt;/h3&gt;

&lt;p&gt;When the auth-proxy calls a backend Cloud Run service, it uses &lt;a href="https://cloud.google.com/run/docs/authenticating/service-to-service" rel="noopener noreferrer"&gt;Cloud Run service-to-service authentication&lt;/a&gt;. This is a two-stage mechanism: "the caller proves who they are with a Google-signed &lt;strong&gt;ID token&lt;/strong&gt;, and the receiving Cloud Run service compares it against the IAM policy to decide whether to admit it."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[auth-proxy SA] ── Authorization: Bearer &amp;lt;ID token (aud=backend URL)&amp;gt; ──▶ [Cloud Run frontend]
                                                                            ① Verify ID token
                                                                            ② Check via IAM whether
                                                                               the issuing principal
                                                                               has roles/run.invoker
                                                                            ③ OK → route to container
                                                                               NG → return 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common misunderstanding here is that &lt;strong&gt;"as long as you send an ID token, you can call it"&lt;/strong&gt; is &lt;em&gt;not&lt;/em&gt; true. The actual decision lives on the IAM side. The ID token is an ID proving "who is calling," and "whether to let that identity in" is determined by who has been granted &lt;code&gt;roles/run.invoker&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grantee of &lt;code&gt;roles/run.invoker&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allUsers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Anyone can call it without an ID token&lt;/strong&gt; (open to the internet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A specific service account&lt;/td&gt;
&lt;td&gt;Only ID tokens issued by that SA can call it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not granted&lt;/td&gt;
&lt;td&gt;No one can call it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This time, I grant &lt;code&gt;roles/run.invoker&lt;/code&gt; &lt;strong&gt;only to the auth-proxy's service account&lt;/strong&gt;. With &lt;code&gt;ingress: internal&lt;/code&gt; blocking direct external access, IAM also blocks direct hits from other services inside the VPC.&lt;/p&gt;

&lt;h4&gt;
  
  
  What Happens If You Set &lt;code&gt;allUsers&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;A common antipattern is "it doesn't work, so I'll just grant invoker to &lt;code&gt;allUsers&lt;/code&gt;." If you do that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Even with &lt;code&gt;ingress: internal&lt;/code&gt; left in place, any resource inside the VPC can hit it without an ID token&lt;/li&gt;
&lt;li&gt;If you have &lt;code&gt;ingress: all&lt;/code&gt;, anyone on the internet can hit it without an ID token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, &lt;strong&gt;playwright-mcp effectively becomes a wild API&lt;/strong&gt;. Since the Storage State carries Google-logged-in credentials, the damage isn't limited to data leakage—it can extend to &lt;strong&gt;all resources operable with that account's permissions&lt;/strong&gt;. It's appropriate to keep checking grants of &lt;code&gt;roles/run.invoker&lt;/code&gt; constantly during implementation.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where ID Tokens Come From and How They're Refreshed
&lt;/h4&gt;

&lt;p&gt;The &lt;a href="https://cloud.google.com/run/docs/authenticating/service-to-service#metadata-server" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; lists multiple retrieval paths, but when calling Cloud Run from a service running on Cloud Run, &lt;strong&gt;in practice the source is consolidated into a single metadata server&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Querying the &lt;strong&gt;metadata server&lt;/strong&gt; (&lt;code&gt;http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=...&lt;/code&gt;) returns an ID token for the service account bound to the instance&lt;/li&gt;
&lt;li&gt;The token's lifetime is about 1 hour, and you need to fetch a new one before it expires&lt;/li&gt;
&lt;li&gt;Google's official auth libraries (in Go, &lt;code&gt;google.golang.org/api/idtoken&lt;/code&gt;) hit the same metadata server internally and handle retrieval, caching, and refresh for you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other options listed in the official docs (&lt;strong&gt;Workload Identity Federation&lt;/strong&gt; and &lt;strong&gt;downloaded service account keys&lt;/strong&gt;) are mechanisms for calling Cloud Run "from outside Google Cloud." In our case, where we run on Cloud Run, the metadata server is directly usable, so there's no reason to adopt them. Distributing SA keys as files in particular brings in the separate operational headache of key storage and rotation, which is even more reason to avoid it.&lt;/p&gt;

&lt;p&gt;In implementation terms, the choice boils down to "hit the metadata server yourself" or "delegate to the auth library," but there's not much reason to choose the former. Including token-expiration handling and cache consistency under concurrent requests, leaning on the library results in fewer accidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caller Code
&lt;/h3&gt;

&lt;p&gt;On the calling side, you create a client by passing the &lt;strong&gt;audience (the receiving service's origin URL)&lt;/strong&gt; to &lt;code&gt;idtoken.NewClient&lt;/code&gt;. Specify &lt;code&gt;https://&amp;lt;service&amp;gt;.run.app&lt;/code&gt; of the destination Cloud Run as &lt;code&gt;audience&lt;/code&gt;. This is the value placed in the ID token's &lt;code&gt;aud&lt;/code&gt; claim, which the receiving Cloud Run uses to determine "is this token addressed to me?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;idtoken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// audience = "https://&amp;lt;backend&amp;gt;.run.app"&lt;/span&gt;
&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathID&lt;/span&gt;                    &lt;span class="c"&gt;// e.g. "/playwright-mcp-team-a"&lt;/span&gt;

&lt;span class="n"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;httputil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReverseProxy&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Director&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backendURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scheme&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backendURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backendURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// don't forward the API key to the backend&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// automatically attaches and refreshes ID tokens&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is passing &lt;code&gt;client.Transport&lt;/code&gt; to &lt;code&gt;ReverseProxy.Transport&lt;/code&gt;. With just this, every request the auth-proxy relays automatically gets an ID token (fetched from the metadata server) attached and refreshed. &lt;code&gt;ReverseProxy&lt;/code&gt; can also pass through long-lived streaming responses like SSE as-is, so it pairs well with Streamable HTTP MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Stateful Caveat: &lt;code&gt;maxScale=1&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Authentication is now plugged, but Playwright MCP also has the operational constraint that it &lt;strong&gt;fundamentally can't scale out&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Streamable HTTP Is Stateful
&lt;/h3&gt;

&lt;p&gt;The transport currently recommended for MCP is &lt;strong&gt;Streamable HTTP&lt;/strong&gt;. To make sense of why this is stateful, you need to grasp two things: "the difference from a regular POST" and "what MCP is actually exchanging."&lt;/p&gt;

&lt;h4&gt;
  
  
  The Difference Between a Regular POST and SSE
&lt;/h4&gt;

&lt;p&gt;Roughly speaking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;regular HTTP POST&lt;/strong&gt; is "exchanging letters." The client sends one letter, the server writes one reply, and that's it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE (Server-Sent Events)&lt;/strong&gt; is "a phone call." Once connected, the server can speak &lt;strong&gt;as many times as it wants, whenever it wants&lt;/strong&gt;. The line stays open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, consider asking Playwright MCP to "take a screenshot of this page." The internal processing is "navigate to page → wait for load → scroll → capture → encode," which takes a fair amount of time.&lt;/p&gt;

&lt;p&gt;With a regular POST, what the client sees is something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──"take a shot"──▶ server
(10 seconds pass; nothing happens)
client ◀──"here's your shot (image data)"── server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until the entire body is complete, nothing reaches the client. Meanwhile, you can't even tell whether it's "dead or working," so it's not suited for long-running jobs.&lt;/p&gt;

&lt;p&gt;With SSE, the same processing looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──"take a shot"──▶ server
client ◀──"navigated to page"── server    (connection still open)
client ◀──"waiting for load"── server
client ◀──"scrolled"── server
client ◀──"here's your shot (image data)"── server
(server closes here)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual response body is &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt;, with text appended bit by bit, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"notifications/progress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"progress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"notifications/progress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"progress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;data:&lt;/code&gt; line plus one blank line is the boundary for one message. The client can process each message &lt;strong&gt;incrementally as it reads&lt;/strong&gt; the response body.&lt;/p&gt;

&lt;p&gt;MCP's spec says "if the response is short, you may return regular &lt;code&gt;application/json&lt;/code&gt;" and "if you want to return multiple messages, you may use SSE," and the server switches based on the situation.&lt;/p&gt;

&lt;h4&gt;
  
  
  What Sessions Are For
&lt;/h4&gt;

&lt;p&gt;That covers "one request's worth," but there's another concept one level above between MCP clients and servers: &lt;strong&gt;the session&lt;/strong&gt;. The reason is that MCP itself is a stateful protocol. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a connection is opened, the client first sends &lt;code&gt;initialize&lt;/code&gt;, &lt;strong&gt;negotiating each side's capabilities&lt;/strong&gt;. It's here that "what tools this server has" and "what notifications it supports" are determined and assumed thereafter&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;subscribe state&lt;/strong&gt; of resources (like "notify me when this file changes") is also remembered by the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ID that links these states to "which client they belong to" is the &lt;strong&gt;&lt;code&gt;Mcp-Session-Id&lt;/code&gt; header&lt;/strong&gt;. The server issues it in the &lt;code&gt;initialize&lt;/code&gt; response, and the client includes the same value in every subsequent request. It’s easier to picture as a cookie translated into an HTTP header.&lt;/p&gt;

&lt;h4&gt;
  
  
  What Playwright MCP Carries
&lt;/h4&gt;

&lt;p&gt;A Playwright MCP session, on top of the MCP protocol state above, is tied to &lt;strong&gt;a live Chromium process + open pages + cookies + ongoing operations&lt;/strong&gt;. These are &lt;strong&gt;live process state&lt;/strong&gt; stuck to a particular Cloud Run instance's memory and OS resources, so transferring them to another instance isn't realistic.&lt;/p&gt;

&lt;p&gt;In other words, it's not the kind of thing where "if you save the session ID somewhere, another instance can pick up where you left off," which is the key point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compatibility With Cloud Run Scaling
&lt;/h3&gt;

&lt;p&gt;Cloud Run grows instances based on request count. If a Streamable HTTP client's second-or-later request lands on a different instance, of course no session exists there, and it fails with &lt;code&gt;Session not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most reliable countermeasure is &lt;strong&gt;not to grow the instances&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;autoscaling.knative.dev/maxScale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
    &lt;span class="na"&gt;autoscaling.knative.dev/minScale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt; &lt;span class="c1"&gt;# collapse to zero when not in use&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For internal batch use cases or low-headcount interactive use cases, one instance is usually enough. Leaving &lt;code&gt;minScale=0&lt;/code&gt; keeps cost down to just cold-start requests.&lt;/p&gt;

&lt;p&gt;Note: the auth-proxy itself is stateless and could scale out, but in this case the caller is limited to a single n8n service and traffic is light, so I match it with &lt;code&gt;maxScale=1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Why Not Session Affinity?"
&lt;/h3&gt;

&lt;p&gt;You might think, "Rather than fixing the instance, can't we just stick the same client to the same instance?" Cloud Run does have &lt;a href="https://docs.cloud.google.com/run/docs/configuring/session-affinity" rel="noopener noreferrer"&gt;session affinity&lt;/a&gt;, and it looks like it could work. But it doesn't help Playwright MCP. There are two reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Affinity is best-effort, and doesn't keep instances alive&lt;/strong&gt;. The official docs explicitly say "do not use it to store server-side session data that needs to persist across requests and cannot easily be reconstructed." Affinity breaks at any of: scale-in, max concurrency, or CPU limits, and at that moment you lose the live Chromium process along with it. A session that holds "state that can't be reconstructed"—exactly our case—is the very use case the official docs name and recommend against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The identification paths don't line up&lt;/strong&gt;. Cloud Run affinity identifies clients via a proprietary cookie issued by the GCLB, but MCP's session identifier is the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header. The two are unrelated, and there's no guarantee an MCP client retains and sends back that cookie.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The conventional approach to surviving scale-out is "offload state to an external store like Redis, and keep instances themselves stateless." MCP's &lt;strong&gt;protocol state&lt;/strong&gt; (capabilities and subscribe state) can be externalized this way, but &lt;strong&gt;a live Chromium process + open pages + ongoing operations&lt;/strong&gt; isn't the kind of thing you can serialize and offload. Affinity is an optimization for apps that can "rebuild state when broken," and for Playwright MCP—where the live process itself &lt;em&gt;is&lt;/em&gt; the session—&lt;strong&gt;it doesn't act as a fix, only as an optimization&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the end, since you can't externalize the state, the only sure move is not to grow instances. When you need to scale, expand by "adding services per team" rather than "adding more replicas of one service."&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sidecar is hit straight through from n8n&lt;/td&gt;
&lt;td&gt;Switch to going via auth-proxy and authenticate with &lt;code&gt;Mcp-Auth-Key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct reach from the internet&lt;/td&gt;
&lt;td&gt;Eliminate external entry points with &lt;code&gt;ingress: internal&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct hits from other services in the VPC&lt;/td&gt;
&lt;td&gt;Grant &lt;code&gt;roles/run.invoker&lt;/code&gt; only to the auth-proxy SA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity proof for service-to-service traffic&lt;/td&gt;
&lt;td&gt;Auto-attach and auto-refresh ID tokens with &lt;code&gt;idtoken.NewClient&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session isolation between teams&lt;/td&gt;
&lt;td&gt;Route by the first URL segment and per-team API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintaining Google login state&lt;/td&gt;
&lt;td&gt;Mount Storage State via Secret Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stateful and unscalable&lt;/td&gt;
&lt;td&gt;Pin instances with &lt;code&gt;maxScale=1&lt;/code&gt; (scale per team)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you plug things at the four layers of network, IAM, service-to-service auth, and application, attack surfaces—each of which can't stand on its own—are eliminated together. This setup should work as a general-purpose pattern for &lt;strong&gt;safely exposing stateful MCP servers on Cloud Run&lt;/strong&gt;, not just Playwright MCP. I hope it's useful for teams that want to expose an MCP server internally but are unsure how to wire up authentication.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>playwright</category>
      <category>cloudrun</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Automatically Merge Dependabot Patch Updates with GitHub Actions</title>
      <dc:creator>h6o</dc:creator>
      <pubDate>Wed, 03 Dec 2025 00:25:28 +0000</pubDate>
      <link>https://dev.to/newbee1939/automatically-merge-dependabot-patch-updates-with-github-actions-316g</link>
      <guid>https://dev.to/newbee1939/automatically-merge-dependabot-patch-updates-with-github-actions-316g</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Dependabot automatically detects dependency updates and creates pull requests, but manually merging each one can be tedious.&lt;/p&gt;

&lt;p&gt;Patch updates (security fixes and bug fixes) typically have limited impact, making them safe candidates for automatic merging.&lt;/p&gt;

&lt;p&gt;This article explains how to implement a GitHub Actions workflow that automatically merges Dependabot patch updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow Overview
&lt;/h2&gt;

&lt;p&gt;The following workflow automatically merges only patch updates (&lt;code&gt;version-update:semver-patch&lt;/code&gt;) from Dependabot pull requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dependabot auto-merge&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;opened&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;synchronize&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reopened&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ready_for_review&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dependabot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.user.login == 'dependabot[bot]'&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fetch Dependabot metadata&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metadata&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependabot/fetch-metadata@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto-merge Dependabot patch updates&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.metadata.outputs.update-type == 'version-update:semver-patch'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh pr merge --merge --auto "$PR_URL"&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PR_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.html_url }}&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Detailed Explanation of Each Step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trigger Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;opened&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;synchronize&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reopened&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ready_for_review&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pull_request_target&lt;/code&gt;: Runs in the context of the branch where the pull request was created. This allows proper access to Dependabot's pull requests with the necessary permissions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opened&lt;/code&gt;: When a pull request is created&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;synchronize&lt;/code&gt;: When new commits are pushed to the pull request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reopened&lt;/code&gt;: When a closed pull request is reopened&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ready_for_review&lt;/code&gt;: When a draft pull request becomes ready for review&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Job Condition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.user.login == 'dependabot[bot]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This condition ensures the job only runs for pull requests created by Dependabot. It prevents accidental automatic merging of pull requests created by other users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permission Settings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;contents: write&lt;/code&gt;: Write access to the repository (required for merging)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pull-requests: write&lt;/code&gt;: Pull request operation permissions (required for merging)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Fetch Dependabot Metadata
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fetch Dependabot metadata&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metadata&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependabot/fetch-metadata@v2&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dependabot/fetch-metadata@v2&lt;/code&gt; action retrieves metadata about Dependabot's pull request. This action outputs information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;update-type&lt;/code&gt;: Type of update (&lt;code&gt;version-update:semver-patch&lt;/code&gt;, &lt;code&gt;version-update:semver-minor&lt;/code&gt;, &lt;code&gt;version-update:semver-major&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dependency-names&lt;/code&gt;: Names of the dependencies being updated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;directory&lt;/code&gt;: Directory where the update occurred&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Auto-merge Patch Updates
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto-merge Dependabot patch updates&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.metadata.outputs.update-type == 'version-update:semver-patch'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh pr merge --merge --auto "$PR_URL"&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;PR_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.html_url }}&lt;/span&gt;
    &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;if&lt;/code&gt; condition: Only executes when the update type is a patch update (&lt;code&gt;version-update:semver-patch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gh pr merge --merge --auto&lt;/code&gt;: Uses GitHub CLI to merge the pull request

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--merge&lt;/code&gt;: Creates a merge commit to merge&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--auto&lt;/code&gt;: Automatically merges once all checks pass&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setup Instructions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create the Workflow File
&lt;/h3&gt;

&lt;p&gt;Save the workflow above in &lt;code&gt;.github/workflows/dependabot-auto-merge.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Verify Dependabot Configuration
&lt;/h3&gt;

&lt;p&gt;Ensure Dependabot is enabled in &lt;code&gt;dependabot.yml&lt;/code&gt; or in your GitHub repository settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes and Best Practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Only Auto-merge Patch Updates?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patch updates (1.0.0 → 1.0.1)&lt;/strong&gt;: Bug fixes and security patches. Safe to auto-merge as they don't contain breaking changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minor updates (1.0.0 → 1.1.0)&lt;/strong&gt;: New features added. May have broader impact, so review is recommended&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Major updates (1.0.0 → 2.0.0)&lt;/strong&gt;: Likely to contain breaking changes. Manual review is essential&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;By implementing this workflow, you can automatically merge Dependabot patch updates and quickly apply security patches and bug fixes. Patch updates typically don't contain breaking changes, making them safe for automatic merging.&lt;/p&gt;

&lt;p&gt;However, we recommend adjusting the auto-merge conditions based on your project's characteristics and team policies. Consider customizing the workflow for critical dependencies by requiring manual reviews or adding additional checks.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>github</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>3 ways to speed up CI [GitHub Actions] that you can do immediately!</title>
      <dc:creator>h6o</dc:creator>
      <pubDate>Thu, 26 Dec 2024 23:02:54 +0000</pubDate>
      <link>https://dev.to/newbee1939/3-ways-to-speed-up-ci-github-actions-that-you-can-do-immediately-28cc</link>
      <guid>https://dev.to/newbee1939/3-ways-to-speed-up-ci-github-actions-that-you-can-do-immediately-28cc</guid>
      <description>&lt;p&gt;&lt;strong&gt;For those&lt;/strong&gt; who are frustrated by slow CI execution.&lt;/p&gt;

&lt;p&gt;Here are three ways to speed up CI execution with GitHub Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to speed up CI [GitHub Actions].
&lt;/h2&gt;

&lt;p&gt;The following three methods are introduced in this article.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Split the Job&lt;/li&gt;
&lt;li&gt;Adding package cache processing&lt;/li&gt;
&lt;li&gt;Split tests and run them in parallel&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Split a Job
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Jobs can be split so that each job runs in parallel&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, the execution of a unit test and the execution of a Linter can often run independently.&lt;/p&gt;

&lt;p&gt;It would be more efficient to describe them in separate Jobs, rather than in series in a single Job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;jobs:.&lt;/span&gt;
  &lt;span class="s"&gt;test:.&lt;/span&gt;
    &lt;span class="s"&gt;runs-on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="s"&gt;steps:.&lt;/span&gt;
    &lt;span class="s"&gt;...&lt;/span&gt;

  &lt;span class="s"&gt;lint&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;...&lt;/span&gt;
    &lt;span class="s"&gt;runs-on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="s"&gt;steps&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;...&lt;/span&gt;
    &lt;span class="s"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Add package caching process.
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Packages are recommended to be cached to skip the time-consuming package installation process&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Use the official &lt;a href="https://github.com/actions/cache" rel="noopener noreferrer"&gt;actions/cache&lt;/a&gt; to implement the cache process.&lt;/p&gt;

&lt;p&gt;In the following cases, &lt;code&gt;npm ci&lt;/code&gt; will only be executed if there is a change in the OS, Node version or the file that manages package information (package-lock.json), otherwise the cache will be used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache and restore packages&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache-npm&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4.0.2&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_modules&lt;/span&gt;
    &lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_modules&lt;/span&gt;
    &lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-${{ steps.tool_versions.outputs.nodejs }}-${{ hashFiles(‘**/package-lock.json’) }}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;install npm packages&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.cache-npm.outputs.cache-hit ! = ‘true’&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Split and run tests in parallel
&lt;/h3&gt;

&lt;p&gt;If your tests take a long time to run, you can speed up the process by &lt;strong&gt;dividing the tests and running each one in parallel&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, in the case of Jest, you can use the &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow" rel="noopener noreferrer"&gt;matrix strategy&lt;/a&gt; and the command option &lt;a href="https://jestjs.io/docs/cli#--shard" rel="noopener noreferrer"&gt;--shard&lt;/a&gt;. The &lt;code&gt;matrix strategy&lt;/code&gt; is a simple and easy way to split up tests and run them in parallel.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;matrix strategy&lt;/code&gt; is a method to run a Job for each value defined in a variable within a single Job, and &lt;code&gt;--shard&lt;/code&gt; is an option to split tests.&lt;/p&gt;

&lt;p&gt;Using these, you can define a workflow like the following.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1/4&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2/4&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3/4&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4/4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;setup environment&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/setup&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;run test&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx jest --ci --shard=${{ matrix.shard }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will run &lt;strong&gt;4 Jobs in parallel, each running a quarter of the tests&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I don't know if there are other options like &lt;code&gt;--shard&lt;/code&gt; besides Jest, but the idea itself can be applied to any language.&lt;/p&gt;

&lt;h2&gt;
  
  
  There are other ways.
&lt;/h2&gt;

&lt;p&gt;The following three methods were introduced as easy ways to improve the speed of CI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Split the Job&lt;/li&gt;
&lt;li&gt;Add package cache processing&lt;/li&gt;
&lt;li&gt;Split tests and run them in parallel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, in addition to these, you can also use &lt;a href="https://docs.github.com/en/actions/using-github-hosted-runners/using-larger-runners/about-larger-runners" rel="noopener noreferrer"&gt;larger runner&lt;/a&gt; and running tests only in areas where changes have been made, there are many other ways to improve speed.&lt;/p&gt;

&lt;p&gt;It is recommended to improve the speed little by little to the extent possible, recognising the time and financial resources available.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
