<?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: Ankur K</title>
    <description>The latest articles on DEV Community by Ankur K (@ankurk91).</description>
    <link>https://dev.to/ankurk91</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F65811%2F092555e2-eae8-40ec-bf8b-7dc07f46ba06.jpeg</url>
      <title>DEV Community: Ankur K</title>
      <link>https://dev.to/ankurk91</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ankurk91"/>
    <language>en</language>
    <item>
      <title>Stop Whitelisting Port 22: SSH into Private EC2 from GitHub Actions via AWS SSM 🔐</title>
      <dc:creator>Ankur K</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:24:14 +0000</pubDate>
      <link>https://dev.to/ankurk91/stop-whitelisting-port-22-ssh-into-private-ec2-from-github-actions-via-aws-ssm-cj4</link>
      <guid>https://dev.to/ankurk91/stop-whitelisting-port-22-ssh-into-private-ec2-from-github-actions-via-aws-ssm-cj4</guid>
      <description>&lt;h2&gt;
  
  
  😩 The part of CI/CD nobody enjoys
&lt;/h2&gt;

&lt;p&gt;You want a GitHub Actions job to &lt;code&gt;rsync&lt;/code&gt; a build onto an EC2 box and restart a service. Simple, right?&lt;/p&gt;

&lt;p&gt;Then reality shows up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔑 &lt;strong&gt;The key.&lt;/strong&gt; You generate an SSH key pair, paste the private half into &lt;code&gt;secrets.SSH_PRIVATE_KEY&lt;/code&gt;, and append the public half to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on the instance. That key now lives forever. It never rotates. Anyone who can read repo secrets — or any action you &lt;code&gt;uses:&lt;/code&gt; that decides to be clever — has shell on production.&lt;/li&gt;
&lt;li&gt;🌍 &lt;strong&gt;Port 22.&lt;/strong&gt; GitHub-hosted runners come from a huge, &lt;em&gt;changing&lt;/em&gt; pool of egress IPs. So you either open &lt;code&gt;22/tcp&lt;/code&gt; to &lt;code&gt;0.0.0.0/0&lt;/code&gt; (🙈), or you write a scheduled job that pulls GitHub's meta API and rewrites your security group ingress rules — dozens of CIDRs, churning weekly, on every instance you deploy to.&lt;/li&gt;
&lt;li&gt;🏰 &lt;strong&gt;The bastion.&lt;/strong&gt; The "proper" fix. Now you have an extra instance to patch, monitor, pay for, and whose own key you also have to manage. Congratulations, the problem has a second copy of itself.&lt;/li&gt;
&lt;li&gt;🧟 &lt;strong&gt;The leftovers.&lt;/strong&gt; A public IP on a box that has no business having one. A key on an ex-employee's laptop. A &lt;code&gt;known_hosts&lt;/code&gt; prompt that hangs a job at 2am.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is accepted as "just how deploys work." It isn't, anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Enter AWS Systems Manager Session Manager
&lt;/h2&gt;

&lt;p&gt;Session Manager flips the direction of the connection. 🔄&lt;/p&gt;

&lt;p&gt;The SSM Agent on your instance makes an &lt;strong&gt;outbound&lt;/strong&gt; HTTPS connection to AWS and holds it open. When you want in, you ask the SSM API for a session, and AWS brokers the two ends together over that existing channel.&lt;/p&gt;

&lt;p&gt;Read that again, because everything good follows from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🚫 &lt;strong&gt;Zero inbound rules.&lt;/strong&gt; Security group ingress can be completely empty. Port 22 closed. To everyone. Forever.&lt;/li&gt;
&lt;li&gt;🕳️ &lt;strong&gt;No public IP, no bastion.&lt;/strong&gt; Private subnet instances work identically. Behind a NAT gateway, or with no internet at all if you add the three VPC interface endpoints.&lt;/li&gt;
&lt;li&gt;🪪 &lt;strong&gt;IAM is the auth layer.&lt;/strong&gt; Access is an IAM policy, not a file on a disk. Revoke a role and access dies instantly — no hunting for &lt;code&gt;authorized_keys&lt;/code&gt; entries.&lt;/li&gt;
&lt;li&gt;📜 &lt;strong&gt;CloudTrail sees every session start,&lt;/strong&gt; attributed to the identity that opened it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the underrated trick: the &lt;code&gt;AWS-StartSSHSession&lt;/code&gt; document turns that broker into a &lt;strong&gt;raw byte tunnel&lt;/strong&gt;, which OpenSSH will happily use as a &lt;code&gt;ProxyCommand&lt;/code&gt;. Meaning SSH itself still runs — end-to-end encrypted, host keys and all — it just stops caring about routing.&lt;/p&gt;

&lt;p&gt;Pair it with &lt;strong&gt;EC2 Instance Connect&lt;/strong&gt; (&lt;code&gt;SendSSHPublicKey&lt;/code&gt;) and the last piece falls over too: you push a freshly generated public key into instance metadata, where &lt;code&gt;sshd&lt;/code&gt; picks it up for &lt;strong&gt;60 seconds&lt;/strong&gt; and then forgets it. 🔥 An SSH key with a one-minute shelf life. Nothing to rotate, nothing to leak.&lt;/p&gt;

&lt;p&gt;That's the whole idea. The annoying part is the boilerplate: generate a key, push it, write a &lt;code&gt;ProxyCommand&lt;/code&gt; block into &lt;code&gt;~/.ssh/config&lt;/code&gt;, get the host-key checking right, and tear it all down afterwards.&lt;/p&gt;

&lt;p&gt;So I packaged it. 📦&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 The action
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action" rel="noopener noreferrer"&gt;&lt;code&gt;ankurk91/setup-ssh-over-ssm-action&lt;/code&gt;&lt;/a&gt; does the setup and the cleanup. It doesn't wrap &lt;code&gt;ssh&lt;/code&gt; — it configures the runner, then gets out of your way.&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;Deploy&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;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&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;id-token&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;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&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;deploy&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-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="pi"&gt;-&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;aws-actions/configure-aws-credentials@v6&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AWS_IAM_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&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;ankurk91/setup-ssh-over-ssm-action@v1&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;instance-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.EC2_INSTANCE_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;os-user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu&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="s"&gt;ssh ssm-target 'uptime'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secrets. No key. No security group rule. 🎉 OIDC gets short-lived AWS credentials, the action does the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧩 What it actually wrote
&lt;/h3&gt;

&lt;p&gt;That middle step generates an ephemeral ed25519 key, pushes it via EC2 Instance Connect, and drops a fenced block at the top of &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; ssm-target
  &lt;span class="k"&gt;HostName&lt;/span&gt; i-0123456789abcdef0
  &lt;span class="k"&gt;User&lt;/span&gt; ubuntu
  &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; "/home/runner/.ssh/ssm-ssm-target-i-0123456789abcdef0-9f3c1a2b"
  &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
  &lt;span class="k"&gt;StrictHostKeyChecking&lt;/span&gt; accept-new
  &lt;span class="k"&gt;UserKnownHostsFile&lt;/span&gt; "/home/runner/.ssh/ssm-ssm-target-i-0123456789abcdef0-9f3c1a2b.known_hosts"
  &lt;span class="k"&gt;ServerAliveInterval&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="k"&gt;ControlMaster&lt;/span&gt; &lt;span class="no"&gt;auto&lt;/span&gt;
  &lt;span class="k"&gt;ControlPath&lt;/span&gt; "/home/runner/.ssh/ssm-9f3c1a2b.sock"
  &lt;span class="k"&gt;ControlPersist&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;h
  &lt;span class="k"&gt;ProxyCommand&lt;/span&gt; sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="k"&gt;parameters&lt;/span&gt; 'portNumber=%p' --region us-east-1 &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="k"&gt;reason&lt;/span&gt; 'setup-ssh-over-ssm-action/18273645/1/9f3c1a2b'"
  &lt;span class="k"&gt;Match&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth pointing at. The &lt;code&gt;--reason&lt;/code&gt; stamp carries the workflow run id, so every tunnel is labelled in the Session Manager console and in CloudTrail — and the post step can find exactly its own sessions later. And &lt;code&gt;Match all&lt;/code&gt; closes the stanza, because the block goes in &lt;em&gt;first&lt;/em&gt; in the file and must not swallow any global directives you keep above your own &lt;code&gt;Host&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;Everything else is ordinary SSH config, which is the point: &lt;strong&gt;anything that speaks SSH just works&lt;/strong&gt;, unmodified 🛠️&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rsync -az --delete ./build/ ssm-target:/var/www/app/current/&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="s"&gt;scp ./config/production.env ssm-target:/srv/app/.env&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="s"&gt;ssh ssm-target 'cd /srv/app &amp;amp;&amp;amp; ./bin/migrate --no-interaction'&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="s"&gt;ssh ssm-target 'sudo systemctl reload nginx'&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="s"&gt;ansible-playbook -i inventory.yml site.yml&lt;/span&gt;   &lt;span class="c1"&gt;# ansible_host: ssm-target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Name the alias whatever reads well in your pipeline:&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ankurk91/setup-ssh-over-ssm-action@v1&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;instance-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;i-0123456789abcdef0&lt;/span&gt;
    &lt;span class="na"&gt;host-alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-server&lt;/span&gt;
    &lt;span class="na"&gt;os-user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ec2-user&lt;/span&gt;      &lt;span class="c1"&gt;# Amazon Linux&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring your own key instead of the ephemeral one — it must have no passphrase, and it's the required route for hybrid &lt;code&gt;mi-&lt;/code&gt; managed nodes, which EC2 Instance Connect doesn't support:&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ankurk91/setup-ssh-over-ssm-action@v1&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;instance-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mi-0123456789abcdef0&lt;/span&gt;
    &lt;span class="na"&gt;private-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧹 And it cleans up after itself
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;post&lt;/code&gt; step running on &lt;code&gt;always()&lt;/code&gt; removes the config block, deletes the key material, closes the multiplexed master connection, and terminates the SSM sessions carrying this run's marker. Nothing survives the job. 🫧&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ What you need
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;On the runner&lt;/strong&gt; — AWS CLI v2 (recent enough to accept &lt;code&gt;aws ssm start-session --reason&lt;/code&gt;) and the Session Manager plugin. Both are preinstalled on GitHub-hosted Ubuntu runners, so: nothing. On self-hosted, add &lt;a href="https://github.com/ankurk91/install-aws-cli-action" rel="noopener noreferrer"&gt;&lt;code&gt;install-aws-cli-action&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/ankurk91/install-session-manager-plugin-action" rel="noopener noreferrer"&gt;&lt;code&gt;install-session-manager-plugin-action&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the instance&lt;/strong&gt; — Linux, &lt;code&gt;sshd&lt;/code&gt; running (the port can stay firewalled shut), SSM Agent ≥ 2.3.672.0, &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; on the instance profile, and the &lt;code&gt;ec2-instance-connect&lt;/code&gt; package (preinstalled on AL2023 standard and Ubuntu 20.04+).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the runner role&lt;/strong&gt; — &lt;code&gt;ssm:StartSession&lt;/code&gt;, &lt;code&gt;ssm:DescribeInstanceInformation&lt;/code&gt;, &lt;code&gt;ssm:DescribeSessions&lt;/code&gt;, &lt;code&gt;ssm:TerminateSession&lt;/code&gt; and &lt;code&gt;ec2-instance-connect:SendSSHPublicKey&lt;/code&gt;. The ready-to-paste policy, plus the mistakes that cause most failures, are in &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action/blob/main/docs/IAM.md" rel="noopener noreferrer"&gt;docs/IAM.md&lt;/a&gt;. 👀 The big one: scoping &lt;code&gt;ssm:TerminateSession&lt;/code&gt; with &lt;code&gt;${aws:username}&lt;/code&gt; silently does not work under OIDC federation — use a tag condition instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚠️ Caveats
&lt;/h2&gt;

&lt;p&gt;Sharp edges, up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔇 &lt;strong&gt;No command-level audit.&lt;/strong&gt; The tunnel is opaque to AWS, so session logging captures nothing readable and CloudTrail notes only that a session opened. If you need a record of what ran, reach for SSM Run Command instead.&lt;/li&gt;
&lt;li&gt;🐌 &lt;strong&gt;It's a relay, not a pipe.&lt;/strong&gt; Tens of MB move fine; a multi-gig artifact is miserable. Ship those through S3.&lt;/li&gt;
&lt;li&gt;🔓 &lt;strong&gt;You still own &lt;code&gt;sshd&lt;/code&gt;.&lt;/strong&gt; The door moved, it didn't disappear — keep patching it.&lt;/li&gt;
&lt;li&gt;⏱️ &lt;strong&gt;Keys expire after a minute.&lt;/strong&gt; Only the &lt;em&gt;first&lt;/em&gt; connection has to beat the clock (multiplexing carries the rest, for an hour of &lt;code&gt;ControlPersist&lt;/code&gt;), so put the action right before the steps that use it. Long gaps in the pipeline? Pass your own &lt;code&gt;private-key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;🏷️ &lt;strong&gt;Give overlapping jobs their own &lt;code&gt;host-alias&lt;/code&gt;.&lt;/strong&gt; Keys, sockets and known_hosts files are named per run, but the config block is keyed on the alias — so two jobs sharing an alias and a &lt;code&gt;HOME&lt;/code&gt; on one self-hosted runner will tread on each other.&lt;/li&gt;
&lt;li&gt;🐧 &lt;strong&gt;Linux only,&lt;/strong&gt; runner and instance both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Session cleanup, happily, is &lt;em&gt;not&lt;/em&gt; on this list: each run stamps its tunnels with a unique &lt;code&gt;--reason&lt;/code&gt; marker and the post step terminates only those, so concurrent jobs sharing a role, a runner and an instance never cut off each other. The long version of all of this lives in &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action/blob/main/docs/Caveats.md" rel="noopener noreferrer"&gt;docs/Caveats.md&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Long-lived SSH keys in CI secrets and IP-whitelisted port 22 are habits from before Session Manager existed. Swapping them out costs you one step in a workflow file and an IAM policy — and your deploy commands don't change at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔗 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;strong&gt;The action:&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action" rel="noopener noreferrer"&gt;github.com/ankurk91/setup-ssh-over-ssm-action&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🔐 &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action/blob/main/docs/IAM.md" rel="noopener noreferrer"&gt;IAM policies and gotchas&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⚠️ &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action/blob/main/docs/Caveats.md" rel="noopener noreferrer"&gt;Full caveats&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📘 &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html" rel="noopener noreferrer"&gt;AWS: allow SSH connections through Session Manager&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🔌 &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html" rel="noopener noreferrer"&gt;AWS: install the Session Manager plugin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🗝️ &lt;a href="https://docs.aws.amazon.com/ec2-instance-connect/latest/APIReference/API_SendSSHPublicKey.html" rel="noopener noreferrer"&gt;AWS API: &lt;code&gt;SendSSHPublicKey&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🧰 &lt;a href="https://github.com/ankurk91/install-aws-cli-action" rel="noopener noreferrer"&gt;&lt;code&gt;install-aws-cli-action&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://github.com/ankurk91/install-session-manager-plugin-action" rel="noopener noreferrer"&gt;&lt;code&gt;install-session-manager-plugin-action&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;⭐ &lt;strong&gt;If this saved you from writing another security-group-updating cron job, &lt;a href="https://github.com/ankurk91/setup-ssh-over-ssm-action" rel="noopener noreferrer"&gt;star the repo&lt;/a&gt;&lt;/strong&gt; — it's the signal that tells me which parts to keep building, and it helps the next person find it instead of pasting a private key into a secret.&lt;/p&gt;

&lt;p&gt;Issues and PRs welcome. 🙌 Closed port 22 already? Tell me how it went in the comments. 💬&lt;/p&gt;

</description>
      <category>aws</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>🔐 A WireGuard GitHub Action That Actually Tells You When the Tunnel Is Dead</title>
      <dc:creator>Ankur K</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:18:39 +0000</pubDate>
      <link>https://dev.to/ankurk91/a-wireguard-github-action-that-actually-tells-you-when-the-tunnel-is-dead-3edd</link>
      <guid>https://dev.to/ankurk91/a-wireguard-github-action-that-actually-tells-you-when-the-tunnel-is-dead-3edd</guid>
      <description>&lt;p&gt;Your CI job needs to reach something private — a staging database in a VPC, an internal registry, a deploy target behind a firewall. The usual answers are bad: whitelist GitHub's entire runner IP range, or run a self-hosted runner just for network access.&lt;/p&gt;

&lt;p&gt;Better idea: put the runner on your private network for the length of the job. 👉 &lt;a href="https://github.com/ankurk91/wireguard-action" rel="noopener noreferrer"&gt;&lt;strong&gt;ankurk91/wireguard-action&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 What is WireGuard?
&lt;/h2&gt;

&lt;p&gt;A modern VPN protocol, and a small one — ~4,000 lines of code against OpenVPN's hundreds of thousands. It lives in the Linux kernel (mainline since 5.6), so it's fast. Its crypto isn't configurable, so there's nothing to downgrade. And config is just keys, SSH-style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Interface]&lt;/span&gt;
&lt;span class="py"&gt;PrivateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;client private key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.0.0.2/32&lt;/span&gt;

&lt;span class="nn"&gt;[Peer]&lt;/span&gt;
&lt;span class="py"&gt;PublicKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;server public key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;AllowedIPs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
&lt;span class="py"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;vpn.example.com:51820&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Ephemeral, keyed, instant to bring up — a great fit for CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  😤 Why another action?
&lt;/h2&gt;

&lt;p&gt;I went looking and came away unhappy. Not naming names, but the same problems kept showing up: actions pinned to end-of-life &lt;code&gt;node12&lt;/code&gt;/&lt;code&gt;node16&lt;/code&gt;, docs that stop at a YAML snippet, huge bundled &lt;code&gt;node_modules&lt;/code&gt; running as root, no cleanup step, and tunnel state dumped into job logs.&lt;/p&gt;

&lt;p&gt;The big one: &lt;strong&gt;&lt;code&gt;wg-quick up&lt;/code&gt; exits 0 even when your peer is unreachable.&lt;/strong&gt; It creates the interface, adds the routes, reports success — and carries nothing. Your job then fails three steps later with a timeout that looks unrelated, and you burn an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✨ Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🩺 Verifies the handshake.&lt;/strong&gt; After connecting, it sends real traffic and checks &lt;code&gt;wg show&lt;/code&gt; for a completed handshake. Dead tunnel → the step fails &lt;em&gt;here&lt;/em&gt;, with a message telling you to check &lt;code&gt;Endpoint&lt;/code&gt;, keys, and UDP reachability. Skipped for split tunnels, where there's nothing meaningful to test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🧼 Cleans up automatically.&lt;/strong&gt; A &lt;code&gt;post-if: always()&lt;/code&gt; step brings the interface down and &lt;strong&gt;deletes the config file&lt;/strong&gt; on success, failure, or cancellation. No disconnect step to remember — and no private key left behind on a self-hosted runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📦 Zero dependencies.&lt;/strong&gt; No &lt;code&gt;node_modules&lt;/code&gt;, no bundled &lt;code&gt;dist/&lt;/code&gt;. A ten-line &lt;code&gt;node24&lt;/code&gt; shim over readable, linted bash. You can audit the whole thing in five minutes — which matters for something running &lt;code&gt;sudo&lt;/code&gt; on your runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🔍 Opt-in diagnostics.&lt;/strong&gt; &lt;code&gt;diagnostics: true&lt;/code&gt; prints &lt;code&gt;wg show&lt;/code&gt;, addresses, routes, and your public IP before and after. Off by default, because that describes your network and job logs reach more people than your secrets do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📚 Real docs.&lt;/strong&gt; A &lt;a href="https://github.com/ankurk91/wireguard-action#readme" rel="noopener noreferrer"&gt;README&lt;/a&gt; and a &lt;a href="https://github.com/ankurk91/wireguard-action/blob/main/TROUBLESHOOTING.md" rel="noopener noreferrer"&gt;TROUBLESHOOTING.md&lt;/a&gt; written from failures I actually hit — including the two that bite everyone: &lt;strong&gt;GitHub runners have no IPv6&lt;/strong&gt; (strip IPv6 from your config or the tunnel won't start), and &lt;strong&gt;&lt;code&gt;AllowedIPs = 0.0.0.0/0&lt;/code&gt; routes the runner's own connection to GitHub through your VPN&lt;/strong&gt; (if your VPN blocks that, the job hangs).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🚀 Getting started
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Grab a client config from your WireGuard server — the whole &lt;code&gt;wg0.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Add it as a repository secret called &lt;code&gt;WIREGUARD_CONFIG&lt;/code&gt;, pasting the entire file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Never commit the config or inline it — it holds your private key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Add one step:&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;deploy&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-latest&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;Connect to WireGuard VPN&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;ankurk91/wireguard-action@v1&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;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.WIREGUARD_CONFIG }}&lt;/span&gt;

      &lt;span class="c1"&gt;# 👇 Everything below is routed through the VPN.&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;Talk to something private&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;curl -sf http://10.0.0.50:8080/health&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it — &lt;strong&gt;there is no disconnect step to add.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Full config contents. Always from a secret.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interface&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wg0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Written to &lt;code&gt;/etc/wireguard/&amp;lt;interface&amp;gt;.conf&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;diagnostics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Print tunnel state and routes to the log.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Requires&lt;/strong&gt; an Ubuntu runner and an IPv4-only config.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧪 Tested for real
&lt;/h2&gt;

&lt;p&gt;Not "I ran it once." CI stands up a real &lt;code&gt;linuxserver/wireguard&lt;/code&gt; server in Docker, in its own network namespace, and connects to it — a genuine tunnel, no mocks. It then asserts the interface is up, the config is mode &lt;code&gt;600&lt;/code&gt;, both ends agree on the client's public key, ICMP crosses the tunnel, and the byte counters are non-zero.&lt;/p&gt;

&lt;p&gt;That runs &lt;strong&gt;8× per commit&lt;/strong&gt;: &lt;code&gt;ubuntu-24.04&lt;/code&gt; and &lt;code&gt;ubuntu-26.04&lt;/code&gt;, on &lt;strong&gt;x86_64 and ARM&lt;/strong&gt;, with diagnostics both on and off — plus &lt;code&gt;shellcheck&lt;/code&gt; and &lt;code&gt;checkbashisms&lt;/code&gt;, and a monthly schedule so runner-image drift is caught by CI instead of by you on a Friday. It's also in use in real projects; the handshake check exists because of one. 🔬 &lt;a href="https://github.com/ankurk91/wireguard-action/blob/main/.github/workflows/test.yaml" rel="noopener noreferrer"&gt;The test workflow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Performance &amp;amp; 🛡️ Security
&lt;/h2&gt;

&lt;p&gt;It runs before everything else in your job, so it stays light: &lt;strong&gt;no &lt;code&gt;apt-get update&lt;/code&gt;&lt;/strong&gt; on the happy path (it installs from the image's existing indexes and only refreshes if that fails), &lt;code&gt;--no-install-recommends&lt;/code&gt;, skips the install entirely if &lt;code&gt;wg-quick&lt;/code&gt; is present, and &lt;strong&gt;waits for the dpkg lock&lt;/strong&gt; instead of flaking when &lt;code&gt;unattended-upgrades&lt;/code&gt; holds it. The handshake check is bounded to 5s.&lt;/p&gt;

&lt;p&gt;On the security side: the config is &lt;code&gt;chmod 600&lt;/code&gt; (asserted in CI), the &lt;code&gt;interface&lt;/code&gt; input is validated against &lt;code&gt;wg-quick&lt;/code&gt;'s own character class so it can't escape &lt;code&gt;/etc/wireguard&lt;/code&gt;, the key is removed on every exit path, diagnostics are opt-in, and the supply chain is empty. Pin &lt;code&gt;@v1&lt;/code&gt; — or a full SHA if your threat model asks for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔗 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🧩 &lt;a href="https://github.com/ankurk91/wireguard-action" rel="noopener noreferrer"&gt;&lt;strong&gt;The action&lt;/strong&gt;&lt;/a&gt; · &lt;a href="https://github.com/ankurk91/wireguard-action#readme" rel="noopener noreferrer"&gt;README&lt;/a&gt; · &lt;a href="https://github.com/ankurk91/wireguard-action/blob/main/TROUBLESHOOTING.md" rel="noopener noreferrer"&gt;Troubleshooting&lt;/a&gt; · &lt;a href="https://github.com/ankurk91/wireguard-action/issues" rel="noopener noreferrer"&gt;Issues&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌍 &lt;a href="https://www.wireguard.com/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; · &lt;a href="https://www.wireguard.com/papers/wireguard.pdf" rel="noopener noreferrer"&gt;whitepaper&lt;/a&gt; · &lt;a href="https://man7.org/linux/man-pages/man8/wg-quick.8.html" rel="noopener noreferrer"&gt;&lt;code&gt;wg-quick(8)&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🏃 &lt;a href="https://github.com/actions/runner-images" rel="noopener noreferrer"&gt;GitHub runner images&lt;/a&gt; · ⚖️ &lt;a href="https://github.com/ankurk91/wireguard-action/blob/main/LICENSE" rel="noopener noreferrer"&gt;MIT&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;⭐ Star it if it saves you an afternoon.&lt;/p&gt;

</description>
      <category>github</category>
      <category>devops</category>
      <category>cicd</category>
      <category>security</category>
    </item>
    <item>
      <title>Electron to Tauri: A 24-Hour Rewrite with Claude 🚀</title>
      <dc:creator>Ankur K</dc:creator>
      <pubDate>Sun, 06 Sep 2026 09:48:46 +0000</pubDate>
      <link>https://dev.to/ankurk91/i-rewrote-my-electron-app-in-tauri-and-claude-did-100-of-the-work-in-under-24-hours-3j5p</link>
      <guid>https://dev.to/ankurk91/i-rewrote-my-electron-app-in-tauri-and-claude-did-100-of-the-work-in-under-24-hours-3j5p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fak9wj0pnrqpwpikw79n3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fak9wj0pnrqpwpikw79n3.jpg" alt="Google Chat for your desktop — tray icon, desktop notifications, native window, built with Tauri" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR 📌
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🕰️ &lt;strong&gt;Then:&lt;/strong&gt; I built &lt;a href="https://github.com/ankurk91/google-chat-electron" rel="noopener noreferrer"&gt;google-chat-electron&lt;/a&gt; &lt;strong&gt;by hand&lt;/strong&gt;, over &lt;strong&gt;months&lt;/strong&gt;, reading tutorial after tutorial.&lt;/li&gt;
&lt;li&gt;⚡ &lt;strong&gt;Now:&lt;/strong&gt; I rebuilt the whole thing as &lt;a href="https://github.com/ankurk91/google-chat-tauri" rel="noopener noreferrer"&gt;google-chat-tauri&lt;/a&gt; in &lt;strong&gt;less than 24 hours&lt;/strong&gt; — and I did not write the code. &lt;strong&gt;Claude did.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🦀 &lt;strong&gt;Plot twist:&lt;/strong&gt; I don't know Rust. Not a little — &lt;em&gt;at all&lt;/em&gt;. The AI wrote every line of it.&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Result:&lt;/strong&gt; a &lt;strong&gt;3.3 MB&lt;/strong&gt; Linux installer, against &lt;strong&gt;66 MB&lt;/strong&gt; for the Electron build of the same app. Same features. Real numbers, measured off both repos' release assets.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Status:&lt;/strong&gt; &lt;strong&gt;v1.0.1 — stable.&lt;/strong&gt; Six releases in, and the pre-release label is off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in. 👇&lt;/p&gt;




&lt;h2&gt;
  
  
  The Electron Era: Months of Honest, Manual Labour 😅
&lt;/h2&gt;

&lt;p&gt;A few years ago I wanted Google Chat in a real window — with a tray icon, an unread badge and native notifications — instead of a browser tab that disappears among thirty other browser tabs.&lt;/p&gt;

&lt;p&gt;So I built it. In Electron. By hand.&lt;/p&gt;

&lt;p&gt;And it took &lt;strong&gt;months&lt;/strong&gt;. Not because Electron is bad, but because &lt;em&gt;every single thing&lt;/em&gt; was a tutorial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do I make a tray icon that actually behaves?&lt;/li&gt;
&lt;li&gt;How do I keep the app alive when the window closes?&lt;/li&gt;
&lt;li&gt;How do I intercept a link and open it in the &lt;em&gt;real&lt;/em&gt; browser?&lt;/li&gt;
&lt;li&gt;How do I package a &lt;code&gt;.deb&lt;/code&gt;? A &lt;code&gt;.dmg&lt;/code&gt;? An installer for Windows?&lt;/li&gt;
&lt;li&gt;Why does this work on my machine and nowhere else? 🙃&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every answer was a blog post, a GitHub issue thread, or a Stack Overflow reply from 2017 that &lt;em&gt;almost&lt;/em&gt; applied. It shipped, people used it, and I was genuinely proud of it. It's no longer maintained — this is its replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tauri Rewrite: One Evening, One Prompt Loop 🤖
&lt;/h2&gt;

&lt;p&gt;I opened &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; and asked it to port the app to &lt;strong&gt;Tauri v2&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I did not open the Rust book. I did not read the Tauri docs. I described what the app should do, reviewed what came back, ran it on my actual laptop, and reported what broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;15:42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git init&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;17:01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full Electron → Tauri v2 port committed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;17:25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Desktop notifications working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;&amp;lt; 24h later&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;v0.0.1&lt;/code&gt; tagged and released 🎉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;+2 days&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;v1.0.0&lt;/code&gt;, then &lt;code&gt;v1.0.1&lt;/code&gt; — &lt;strong&gt;stable&lt;/strong&gt; ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;72 commits. ~5,100 lines of Rust and JavaScript. Zero lines typed by me.&lt;/p&gt;

&lt;p&gt;This is what people mean by &lt;strong&gt;vibe coding&lt;/strong&gt; — and honestly, it felt less like programming and more like &lt;em&gt;directing&lt;/em&gt;. My job became: describe the behaviour, test it on real hardware, and say "the tray icon doesn't come back on Wayland." The AI handled the rest.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The honest caveat:&lt;/strong&gt; AI wrote it, but a human ran it. Almost every real bug was a platform behaving differently from its own documentation — the kind of thing no model can find by reading docs. That part still needs a person with a laptop and some patience.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Tauri vs Electron: Why the Rewrite Was Worth It ⚖️
&lt;/h2&gt;

&lt;p&gt;Here's the thing — &lt;strong&gt;Electron ships an entire Chromium browser with your app.&lt;/strong&gt; Tauri doesn't. It uses the web engine your operating system &lt;em&gt;already has&lt;/em&gt;: WebKitGTK on Linux, WKWebView on macOS, WebView2 on Windows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;⚛️ Electron&lt;/th&gt;
&lt;th&gt;🦀 Tauri v2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Browser engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ships its own Chromium&lt;/td&gt;
&lt;td&gt;Uses the OS web engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Linux installer size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;66 MB&lt;/strong&gt; (my old app)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.3 MB&lt;/strong&gt; (the same app) 🤯&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory footprint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heavy — it's a browser&lt;/td&gt;
&lt;td&gt;Noticeably lighter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Startup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Boot a Chromium&lt;/td&gt;
&lt;td&gt;Boot a native window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Opt-in hardening&lt;/td&gt;
&lt;td&gt;Capability-based by default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those two size numbers are the &lt;em&gt;same app, same features&lt;/em&gt;, measured off the release assets of both repos. &lt;strong&gt;20× smaller.&lt;/strong&gt; 🤏&lt;/p&gt;

&lt;p&gt;The rest of the bundles land in the same place: &lt;strong&gt;1.9 MB&lt;/strong&gt; for the Windows installer, &lt;strong&gt;6.0 MB&lt;/strong&gt; for the universal macOS &lt;code&gt;.dmg&lt;/code&gt;. The one outlier is the Linux &lt;code&gt;.AppImage&lt;/code&gt; at 85.7 MB — and it's the exception that proves the rule, because it's big precisely &lt;em&gt;because&lt;/em&gt; it carries its own copy of the web engine for distributions that can't supply one.&lt;/p&gt;

&lt;p&gt;The size difference is the headline, but the one you &lt;em&gt;feel&lt;/em&gt; every day is startup and memory. You're not launching a second Chrome just to read messages from your team. The window opens like a native window, because it basically is one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off?&lt;/strong&gt; You inherit the OS web engine's quirks — and they are &lt;em&gt;real&lt;/em&gt;. WebKitGTK renders its failed-load page as literally &lt;code&gt;&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;%s&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&lt;/code&gt; with no styling whatsoever, which on a dark window means black text on a black background. Chromium would never. More on that below. 🧪&lt;/p&gt;




&lt;h2&gt;
  
  
  What the App Actually Does ✨
&lt;/h2&gt;

&lt;p&gt;This isn't a "hello world in a webview." It's a real desktop client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔴 &lt;strong&gt;Unread indicator&lt;/strong&gt; — a dot on the tray icon, the count in the window title, and a badge on the macOS dock or Windows taskbar.&lt;/li&gt;
&lt;li&gt;🔔 &lt;strong&gt;Desktop notifications&lt;/strong&gt; — with sound. On Linux, clicking one brings the window back.&lt;/li&gt;
&lt;li&gt;🗂️ &lt;strong&gt;Lives in the tray&lt;/strong&gt; — closing the window hides it; the app keeps running and keeps notifying.&lt;/li&gt;
&lt;li&gt;🪟 &lt;strong&gt;Remembers your window&lt;/strong&gt; — size, position and maximised state come back where you left them.&lt;/li&gt;
&lt;li&gt;1️⃣ &lt;strong&gt;Single instance&lt;/strong&gt; — launching again just focuses the window you already have.&lt;/li&gt;
&lt;li&gt;📋 &lt;strong&gt;Native menu bar&lt;/strong&gt; — File, Edit, View, History, Preferences, Help, with zoom that persists between launches.&lt;/li&gt;
&lt;li&gt;🚀 &lt;strong&gt;Starts how you like&lt;/strong&gt; — launch at login, and/or start hidden in the tray on any launch.&lt;/li&gt;
&lt;li&gt;⌨️ &lt;strong&gt;Keyboard shortcuts&lt;/strong&gt; — &lt;code&gt;Ctrl+F&lt;/code&gt; to search, &lt;code&gt;Ctrl&lt;/code&gt; &lt;code&gt;+&lt;/code&gt;/&lt;code&gt;-&lt;/code&gt;/&lt;code&gt;0&lt;/code&gt; to zoom, &lt;code&gt;Alt+←&lt;/code&gt;/&lt;code&gt;Alt+→&lt;/code&gt; to navigate, &lt;code&gt;Alt+Home&lt;/code&gt; to get back to Chat, &lt;code&gt;Ctrl+W&lt;/code&gt; to hide to tray.&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Links open in your real browser&lt;/strong&gt; — a Docs, Sheets or Calendar link opens where your extensions and logins already live. Only Chat stays in the window.&lt;/li&gt;
&lt;li&gt;📎 &lt;strong&gt;Attachments download through your browser&lt;/strong&gt; — it saves them the way it saves anything else.&lt;/li&gt;
&lt;li&gt;🔐 &lt;strong&gt;Signs in normally&lt;/strong&gt; — personal Google accounts and paid Workspace accounts, in any country.&lt;/li&gt;
&lt;li&gt;🏢 &lt;strong&gt;SSO sign-in works too&lt;/strong&gt; — Okta, Entra ID, Ping and friends.&lt;/li&gt;
&lt;li&gt;📡 &lt;strong&gt;Handles a missing network gracefully&lt;/strong&gt; — a readable offline page with a &lt;strong&gt;Try again&lt;/strong&gt; button, and it reconnects on its own.&lt;/li&gt;
&lt;li&gt;🧯 &lt;strong&gt;A way back from a wedged session&lt;/strong&gt; — &lt;em&gt;Help → Reset App Data&lt;/em&gt; signs you out, resets every preference and restarts clean.&lt;/li&gt;
&lt;li&gt;🕵️ &lt;strong&gt;Logs you can paste into a public issue&lt;/strong&gt; — paths and URLs go through a redactor first, so nothing in them names you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No analytics. No auto-updater installing things behind your back — it just asks GitHub once or twice a day whether a newer release exists and tells you. You can turn that off. 🙌&lt;/p&gt;

&lt;p&gt;Available for &lt;strong&gt;Linux&lt;/strong&gt; (&lt;code&gt;.deb&lt;/code&gt; + &lt;code&gt;.AppImage&lt;/code&gt;), &lt;strong&gt;macOS&lt;/strong&gt; (universal &lt;code&gt;.dmg&lt;/code&gt;) and &lt;strong&gt;Windows&lt;/strong&gt; (&lt;code&gt;.exe&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bugs Reality Found 🐛
&lt;/h2&gt;

&lt;p&gt;This is the part I'd actually read if someone else wrote this post. Every one of these was found by &lt;em&gt;running the thing&lt;/em&gt;, not by reading docs — and every one of them is a platform doing something other than what it documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  🏢 SSO / identity-provider sign-in
&lt;/h3&gt;

&lt;p&gt;If your company signs you in through Okta, Entra ID or Ping, the flow redirects to &lt;em&gt;your organisation's&lt;/em&gt; host — which the app can't know ahead of time, so it isn't on the short list of hosts allowed to stay in the window. The link opens in your browser and finishes the sign-in &lt;em&gt;there&lt;/em&gt;, leaving the app still signed out. 🤦&lt;/p&gt;

&lt;p&gt;The fix is pleasingly simple: &lt;strong&gt;Preferences → Temporarily Open Every Link in This Window.&lt;/strong&gt; It explains what it's doing before it does anything, keeps every link inside the app for &lt;strong&gt;five minutes&lt;/strong&gt; — long enough to get through any SSO flow — and then &lt;strong&gt;switches itself off again&lt;/strong&gt;. No permanent footgun left enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  🚪 Sign Out that doesn't strand you
&lt;/h3&gt;

&lt;p&gt;After signing out, Google sometimes answers a session-less visit with &lt;code&gt;workspace.google.com&lt;/code&gt; — a &lt;strong&gt;marketing page&lt;/strong&gt; — instead of the login form. And that page's own "Sign in" link did &lt;strong&gt;nothing&lt;/strong&gt;, because the injected script intercepts cross-origin clicks on every page, the IPC permission only covered Google's Chat and Mail domains, and the resulting rejection was silently swallowed. Click → cancelled → dropped into the void. 🕳️&lt;/p&gt;

&lt;p&gt;Now the window navigates itself when Rust can't be asked, and the app redirects off that marketing page to the real sign-in form (at most twice — a redirect loop is worse than a dead end). &lt;strong&gt;You should never have to wipe your data to sign back in.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  📡 An offline screen you can actually read
&lt;/h3&gt;

&lt;p&gt;Remember that WebKitGTK failed-load page? Black text, dark background, one unstyled line. The app &lt;strong&gt;rewrites that document&lt;/strong&gt; into something readable with a &lt;strong&gt;Try again&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;Which, delightfully, could not simply reload — WebKit refuses to navigate its error page to the URL it's standing in for, and the page's opaque origin makes every IPC call fail with &lt;code&gt;Origin header is not a valid URL&lt;/code&gt;. So the button aims at Chat's canonical URL instead, and in the background the app keeps probing the network and &lt;strong&gt;loads Chat by itself within about half a minute&lt;/strong&gt; of your connection coming back. Usually before you even reach for the button. ✨&lt;/p&gt;

&lt;h3&gt;
  
  
  ⌨️ Keyboard shortcuts that were pure decoration
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Alt+Home&lt;/code&gt; was declared on the History menu and answered by &lt;strong&gt;absolutely nothing&lt;/strong&gt; — GTK accelerators never reach the app while focus is inside the webview. Fine, route it through the injected script like every other shortcut.&lt;/p&gt;

&lt;p&gt;Then Windows turned out to be worse. Tauri &lt;em&gt;does&lt;/em&gt; build the accelerator table, and Windows &lt;em&gt;does&lt;/em&gt; draw &lt;code&gt;Ctrl+W&lt;/code&gt; next to the menu item — but the hook that would dispatch it only sees messages that reach the window's own message loop, and WebView2 has focus, always. Measured with synthetic keystrokes: &lt;code&gt;Ctrl+W&lt;/code&gt; does nothing, clicking the exact same menu item works fine. The app now asks WebView2 for those keys directly. 🪟&lt;/p&gt;

&lt;h3&gt;
  
  
  🧊 A notification that froze the window
&lt;/h3&gt;

&lt;p&gt;Showing a desktop notification on Linux measured, over 25 calls: median 48 ms, p90 86 ms, &lt;strong&gt;max 520 ms&lt;/strong&gt;. Tauri runs synchronous commands on the main thread — which, on Wayland, is also the thread drawing your window's own close and minimise buttons. So a notification arriving made the titlebar stop responding, which reads as "the buttons are broken", not "the app is busy". Notifications now go out on a long-lived worker thread. 🧵&lt;/p&gt;

&lt;h3&gt;
  
  
  🤦 The bug I find funniest
&lt;/h3&gt;

&lt;p&gt;The update checker &lt;strong&gt;couldn't see pre-releases.&lt;/strong&gt; Which is a genuinely hilarious flaw in an app whose &lt;em&gt;only&lt;/em&gt; releases were pre-releases. &lt;code&gt;v0.0.1&lt;/code&gt; could never have told you &lt;code&gt;v0.0.2&lt;/code&gt; existed.&lt;/p&gt;

&lt;p&gt;The punchline is that GitHub's &lt;code&gt;/releases/latest&lt;/code&gt; &lt;strong&gt;404s&lt;/strong&gt; for a repo that has only ever pre-released — it's documented as "the most recent non-prerelease release", so with nothing but pre-releases there is no latest &lt;em&gt;at all&lt;/em&gt;, and the 404 is indistinguishable from having never shipped anything. Now that &lt;code&gt;1.0.0&lt;/code&gt; is out, the rule flipped again: only &lt;strong&gt;stable&lt;/strong&gt; releases are ever offered, so a beta never gets pushed at anyone. 🔁&lt;/p&gt;

&lt;h3&gt;
  
  
  🔒 Plus the boring-but-important stuff
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logs stopped leaking identity.&lt;/strong&gt; Every path starts at your home directory, Google puts your email in sign-in URLs, an attachment link carries a bearer token, and a Chat URL's fragment names the open conversation. These files exist to be attached to public GitHub issues, so paths and URLs now go through a redactor on the way in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The injected script got smaller off Chat.&lt;/strong&gt; The full script only runs on Chat's own origin now; everywhere else gets the bare minimum it needs to hand a link to your browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI got teeth.&lt;/strong&gt; Five checks — &lt;code&gt;fmt&lt;/code&gt;, &lt;code&gt;clippy&lt;/code&gt;, &lt;code&gt;cargo test&lt;/code&gt;, a syntax check and the injected script run against a stand-in page — gate the release workflow, so a tag on a commit that fails any of them produces no artifacts at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🚫 And one thing that simply cannot be built
&lt;/h3&gt;

&lt;p&gt;Clicking a notification can't open &lt;em&gt;that conversation&lt;/em&gt;. Chat's real notifications come through the service worker with no click handler the page can reach; the payload's &lt;code&gt;tag&lt;/code&gt; identifies the &lt;strong&gt;sender&lt;/strong&gt;, not the thread; and the document URL never moves off &lt;code&gt;/app/home&lt;/code&gt; as you walk between conversations — confirmed in a stock browser, so it isn't a webview artefact. A click raises the window and stops there. Sometimes the answer is "no", and writing down &lt;em&gt;why&lt;/em&gt; is worth more than another attempt. 🤷&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Things Stand ✅
&lt;/h2&gt;

&lt;p&gt;Let me be &lt;em&gt;very&lt;/em&gt; clear about this part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🎈 &lt;strong&gt;This is a fun project.&lt;/strong&gt; It scratches an itch. It is not a startup.&lt;/li&gt;
&lt;li&gt;🦀 &lt;strong&gt;I don't know Rust.&lt;/strong&gt; If you open an issue about a borrow checker decision, the AI made it, not me.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;It's stable now.&lt;/strong&gt; &lt;code&gt;v1.0.1&lt;/code&gt;. Linux needs glibc 2.39+ (Ubuntu 24.04, Mint 22, Debian 13 and newer), macOS 15 Sequoia and newer, Windows 10 (1803+) or 11.&lt;/li&gt;
&lt;li&gt;🧪 &lt;strong&gt;Linux is where it's been tested most.&lt;/strong&gt; The macOS and Windows builds exist, CI produces them, and they still want real users on real machines. Both are unsigned, so the first launch needs a click through Gatekeeper or SmartScreen — the README spells out exactly which buttons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Google Chat on the desktop, try it and tell me what breaks. Every fix in the section above exists because something broke in real life — that's genuinely the most useful thing you can contribute. 🐛&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian, Ubuntu, Linux Mint&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; ./google-chat-tauri_&lt;span class="k"&gt;*&lt;/span&gt;_linux-amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(The leading &lt;code&gt;./&lt;/code&gt; matters — without a path, &lt;code&gt;apt&lt;/code&gt; goes looking for a package by that name in your repositories.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Takeaway 💭
&lt;/h2&gt;

&lt;p&gt;Months of manual work, then a full rewrite in a different language, on a different framework, in under a day — by someone who can't write that language. Stable a few days later.&lt;/p&gt;

&lt;p&gt;That's not a flex about me. It's a data point about where tooling is in 2026. And the releases since make the shape of it clearer: the hard part was never typing the code. It was &lt;strong&gt;knowing what the app should do&lt;/strong&gt; and &lt;strong&gt;checking it against reality&lt;/strong&gt; — the WebKit error page, the Google marketing redirect, the GTK accelerator that goes nowhere, the notification that freezes a titlebar. AI took the first job, instantly. The second one is still ours. 🤝&lt;/p&gt;

&lt;p&gt;Every one of those findings is written down in the repo — what was measured, and on what — next to a file explaining which bits of code look wrong until you know why. That documentation isn't ceremony. It's the only thing stopping the next change from quietly undoing a fix that cost an afternoon. 📚&lt;/p&gt;




&lt;h2&gt;
  
  
  🔗 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🦀 &lt;strong&gt;Tauri version (new):&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/google-chat-tauri" rel="noopener noreferrer"&gt;https://github.com/ankurk91/google-chat-tauri&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⚛️ &lt;strong&gt;Electron version (original, unmaintained):&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/google-chat-electron" rel="noopener noreferrer"&gt;https://github.com/ankurk91/google-chat-electron&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📥 &lt;strong&gt;Download the latest release:&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/google-chat-tauri/releases/latest" rel="noopener noreferrer"&gt;https://github.com/ankurk91/google-chat-tauri/releases/latest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐛 &lt;strong&gt;Report an issue:&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/google-chat-tauri/issues" rel="noopener noreferrer"&gt;https://github.com/ankurk91/google-chat-tauri/issues&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📚 &lt;strong&gt;Tauri v2 docs:&lt;/strong&gt; &lt;a href="https://v2.tauri.app" rel="noopener noreferrer"&gt;https://v2.tauri.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;Claude Code:&lt;/strong&gt; &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;https://claude.com/claude-code&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Unofficial app. Not affiliated with, endorsed by, or sponsored by Google. "Google Chat" and the Chat logo are trademarks of Google LLC.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you migrated an Electron app to Tauri? Or vibe-coded something bigger than you expected? Drop it in the comments — I'd love to read about it.&lt;/strong&gt; 👇&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Deploy to EC2 from GitHub Actions without opening port 22</title>
      <dc:creator>Ankur K</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:02:47 +0000</pubDate>
      <link>https://dev.to/ankurk91/deploy-to-ec2-from-github-actions-without-opening-port-22-5269</link>
      <guid>https://dev.to/ankurk91/deploy-to-ec2-from-github-actions-without-opening-port-22-5269</guid>
      <description>&lt;p&gt;If you deploy to EC2 from GitHub Actions, the usual recipe is to put a private key in your repo secrets and SSH into the box. It works, but it means you are keeping a long-lived key around and leaving port 22 open to the internet (or to GitHub's very large IP range). AWS has a better answer for this: &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/run-command.html" rel="noopener noreferrer"&gt;Systems Manager Run Command&lt;/a&gt;. The SSM Agent on your instance makes an &lt;strong&gt;outbound&lt;/strong&gt; connection to AWS, and you send commands through the SSM API. There is no inbound port, no key to rotate, and every command is recorded in CloudTrail. Your instance can sit in a private subnet with no public IP at all and this still works.&lt;/p&gt;

&lt;p&gt;I wanted to use this in my own pipelines. I looked around the marketplace and could not find a single action that did it well. Some were thin wrappers around &lt;code&gt;aws ssm send-command&lt;/code&gt; that fired the command and never checked whether it actually succeeded. Some did poll, but swallowed the remote exit code, so a failed deploy showed up as a green build. Others hit the SSM output limit (roughly 24 KB) and truncated my logs right at the interesting part. A few were simply abandoned.&lt;/p&gt;

&lt;p&gt;So I wrote one: &lt;strong&gt;&lt;a href="https://github.com/ankurk91/aws-ssm-run-command-action" rel="noopener noreferrer"&gt;ankurk91/aws-ssm-run-command-action&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSM vs SSH
&lt;/h2&gt;

&lt;p&gt;Both get the job done. Here is how they actually compare for CI/CD:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SSM Run Command&lt;/th&gt;
&lt;th&gt;SSH&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inbound port 22&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;td&gt;Required (or a bastion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public IP on the instance&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;td&gt;Usually needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credentials in CI&lt;/td&gt;
&lt;td&gt;IAM role via OIDC, short-lived&lt;/td&gt;
&lt;td&gt;Long-lived private key in secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key rotation&lt;/td&gt;
&lt;td&gt;Nothing to rotate&lt;/td&gt;
&lt;td&gt;You own the whole rotation dance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance in a private subnet&lt;/td&gt;
&lt;td&gt;Works (NAT or VPC endpoints)&lt;/td&gt;
&lt;td&gt;Needs a bastion or VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who can run what&lt;/td&gt;
&lt;td&gt;IAM policies, scoped per instance or tag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;authorized_keys&lt;/code&gt; on each box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trail&lt;/td&gt;
&lt;td&gt;CloudTrail + SSM command history&lt;/td&gt;
&lt;td&gt;Whatever &lt;code&gt;auth.log&lt;/code&gt; kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revoking access&lt;/td&gt;
&lt;td&gt;Detach the IAM policy&lt;/td&gt;
&lt;td&gt;Edit files on every server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live output streaming&lt;/td&gt;
&lt;td&gt;No, you poll and fetch&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File copy (scp / rsync)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-side setup&lt;/td&gt;
&lt;td&gt;SSM Agent + an IAM role&lt;/td&gt;
&lt;td&gt;sshd + key distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SSM wins on everything that matters for security and access control. SSH keeps two real advantages: live output streaming and file transfer. For a deploy script I have not missed either. The full log lands in S3 anyway, and it is usually better to have the server pull its build artifacts from S3 or a registry than to push them over scp from a runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the action
&lt;/h2&gt;

&lt;p&gt;You need three things on the AWS side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/manually-install-ssm-agent-linux.html" rel="noopener noreferrer"&gt;SSM Agent&lt;/a&gt; on the instance. Amazon Linux and the official Ubuntu AMIs already ship with it.&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-instance-permissions.html" rel="noopener noreferrer"&gt;IAM role attached to the instance&lt;/a&gt; with the &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; managed policy.&lt;/li&gt;
&lt;li&gt;A private S3 bucket for logs. This is how the action gets around the 24 KB output limit. Add a lifecycle rule to delete old objects and forget about it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the workflow:&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;Deploy&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;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&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;id-token&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;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&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;deploy&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-latest&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;Configure AWS Credentials&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;aws-actions/configure-aws-credentials@v6&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AWS_REGION }}&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 commands on EC2&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;ankurk91/aws-ssm-run-command-action@v1&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;ec2_instance_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.EC2_INSTANCE_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;run_as_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu&lt;/span&gt;
          &lt;span class="na"&gt;log_bucket_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.LOG_BUCKET_NAME }}&lt;/span&gt;
          &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -e&lt;/span&gt;
            &lt;span class="s"&gt;cd /var/www/app&lt;/span&gt;
            &lt;span class="s"&gt;git pull --ff-only&lt;/span&gt;
            &lt;span class="s"&gt;npm ci&lt;/span&gt;
            &lt;span class="s"&gt;npx prisma migrate deploy&lt;/span&gt;
            &lt;span class="s"&gt;npm run build&lt;/span&gt;
            &lt;span class="s"&gt;pm2 reload ecosystem.config.js --update-env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole thing. No secrets other than the AWS role, no port 22.&lt;/p&gt;

&lt;p&gt;A few notes from using this in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start your script with &lt;code&gt;set -e&lt;/code&gt;. Without it the shell keeps going after a failed command and reports success.&lt;/li&gt;
&lt;li&gt;The action exposes a &lt;code&gt;command-exit-code&lt;/code&gt; output, so you can branch on it if you need to.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;execution_timeout&lt;/code&gt; defaults to one hour. Lower it for a normal deploy so a hung command does not sit there burning runner minutes.&lt;/li&gt;
&lt;li&gt;Full output lands in your S3 bucket, so nothing gets cut off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pipeline itself only needs &lt;code&gt;ssm:SendCommand&lt;/code&gt;, &lt;code&gt;ssm:ListCommandInvocations&lt;/code&gt; and &lt;code&gt;ssm:GetCommandInvocation&lt;/code&gt;. The full policy is in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: port forwarding through SSM
&lt;/h2&gt;

&lt;p&gt;Run Command is for firing off a script on the server. Sometimes you want a network connection instead. In the deploy above, &lt;code&gt;prisma migrate deploy&lt;/code&gt; runs on the EC2 instance, which is fine. But you may prefer to run migrations from the runner, so that a bad migration fails the pipeline before any new code goes out.&lt;/p&gt;

&lt;p&gt;That needs the runner to reach your database, and your database is almost certainly in a private subnet. &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-sessions-port-forwarding.html" rel="noopener noreferrer"&gt;Session Manager port forwarding&lt;/a&gt; solves it, and &lt;a href="https://github.com/enkhjile/aws-ssm-remote-port-forwarding-action" rel="noopener noreferrer"&gt;enkhjile/aws-ssm-remote-port-forwarding-action&lt;/a&gt; wraps it up nicely. It closes the session in its post step, so there is nothing for you to clean up.&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;Open a tunnel to RDS&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;enkhjile/aws-ssm-remote-port-forwarding-action@v1&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;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.EC2_INSTANCE_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-db.abc123.ap-south-1.rds.amazonaws.com&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
          &lt;span class="na"&gt;local-port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&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 Prisma migrations through the tunnel&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm ci&lt;/span&gt;
          &lt;span class="s"&gt;npx prisma migrate deploy&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;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@127.0.0.1:5432/app?schema=public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prisma just sees a database on localhost. Your EC2 instance is the jump host, but you never log into it, and neither the instance nor the RDS security group needs an inbound rule from the internet.&lt;/p&gt;

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

&lt;p&gt;If you are still shipping a private key to GitHub secrets to deploy to EC2, SSM is worth an afternoon of your time. You delete the key, close the port, and get an audit log for free.&lt;/p&gt;

&lt;p&gt;Links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The action: &lt;a href="https://github.com/ankurk91/aws-ssm-run-command-action" rel="noopener noreferrer"&gt;github.com/ankurk91/aws-ssm-run-command-action&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Port forwarding action: &lt;a href="https://github.com/enkhjile/aws-ssm-remote-port-forwarding-action" rel="noopener noreferrer"&gt;github.com/enkhjile/aws-ssm-remote-port-forwarding-action&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/what-is-systems-manager.html" rel="noopener noreferrer"&gt;What is AWS Systems Manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/run-command.html" rel="noopener noreferrer"&gt;AWS Systems Manager Run Command&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/manually-install-ssm-agent-linux.html" rel="noopener noreferrer"&gt;Install the SSM Agent on Linux&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-instance-permissions.html" rel="noopener noreferrer"&gt;IAM instance profile for Systems Manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-sessions-port-forwarding.html" rel="noopener noreferrer"&gt;Port forwarding with Session Manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-create-vpc.html" rel="noopener noreferrer"&gt;VPC endpoints for Systems Manager&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try the action and something is missing, open an issue on the repo. Stars are appreciated too.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Putting GitHub Actions runners on your private network with NetBird</title>
      <dc:creator>Ankur K</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:05:45 +0000</pubDate>
      <link>https://dev.to/ankurk91/putting-github-actions-runners-on-your-private-network-with-netbird-3hcg</link>
      <guid>https://dev.to/ankurk91/putting-github-actions-runners-on-your-private-network-with-netbird-3hcg</guid>
      <description>&lt;p&gt;Sooner or later a CI job needs to reach something that is not on the public internet. A staging database, an internal&lt;br&gt;
container registry, a deploy target sitting behind a firewall, an integration test suite that talks to a service you&lt;br&gt;
have no intention of exposing.&lt;/p&gt;

&lt;p&gt;The usual answers are not great. You can allowlist GitHub's egress ranges, which are enormous, change regularly, and&lt;br&gt;
effectively mean "allow anyone's CI job". You can move to self-hosted runners and inherit the maintenance. Or you can&lt;br&gt;
run some VPN client in the job and hope the setup survives contact with a fresh container every time.&lt;/p&gt;

&lt;p&gt;I went with the third option, and ended up writing the action I wanted:&lt;br&gt;
&lt;a href="https://github.com/ankurk91/netbird-action" rel="noopener noreferrer"&gt;ankurk91/netbird-action&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  🔐 A short word on NetBird
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://netbird.io" rel="noopener noreferrer"&gt;NetBird&lt;/a&gt; is an overlay network built on WireGuard. Every machine you enrol becomes a peer with a&lt;br&gt;
stable address in the &lt;code&gt;100.64.0.0/10&lt;/code&gt; range, and peers talk to each other directly. There is a control plane that&lt;br&gt;
distributes configuration and access policy, and helps two peers find each other through NAT, but it does not sit in&lt;br&gt;
the data path once a tunnel is up.&lt;/p&gt;

&lt;p&gt;That last part is the real difference from a traditional VPN. A classic setup is hub and spoke: a concentrator with a&lt;br&gt;
public IP and an open port, and everything routed through it. That box is a bottleneck, a single point of failure, and&lt;br&gt;
the one thing on your perimeter that absolutely must never be misconfigured. It also tends to hand out access by&lt;br&gt;
subnet, so being on the VPN means being on the network.&lt;/p&gt;

&lt;p&gt;NetBird works the other way around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No inbound port anywhere.&lt;/strong&gt; Peers dial out to the control plane. Nothing on your side needs a public listener.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct tunnels.&lt;/strong&gt; Traffic goes peer to peer over WireGuard, so latency is whatever the two machines' path is, not a
round trip through a concentrator in another region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access by identity, not by subnet.&lt;/strong&gt; Peers are grouped, and policies say which group can reach which. A runner
joins a group that can reach the staging database and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup keys.&lt;/strong&gt; Machines enrol non-interactively with a key, which is exactly what CI needs. Mark the key
&lt;strong&gt;ephemeral&lt;/strong&gt; and the peer drops out of the dashboard on its own once the job is gone, so you are not left deleting
dead runners by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a free tier that covers a small team, and you can self-host the whole control plane if you would rather.&lt;/p&gt;
&lt;h2&gt;
  
  
  🧩 Why another action
&lt;/h2&gt;

&lt;p&gt;I looked for an existing one first. What I found had not been touched in a long time, installed client versions that&lt;br&gt;
were several releases behind, and was careless in the one place I could not afford carelessness: something that holds a&lt;br&gt;
credential to my entire private network.&lt;/p&gt;

&lt;p&gt;The behaviour that bothered me most was subtler than that, though. Most of them treat "the client reported success" as&lt;br&gt;
"the job can now use the network". Those are not the same moment. A peer registers well before it can actually carry&lt;br&gt;
traffic, and private DNS settles later still. If the next step in your job immediately reaches an internal service, you&lt;br&gt;
get a pipeline that passes four times and fails the fifth, with nothing useful in the log.&lt;/p&gt;

&lt;p&gt;So this action's whole job is to not hand control back until the network is genuinely usable — and to say clearly what&lt;br&gt;
was missing when it cannot.&lt;/p&gt;
&lt;h2&gt;
  
  
  🚀 Getting started
&lt;/h2&gt;

&lt;p&gt;The only required input is the setup key. Create one in the dashboard under &lt;strong&gt;Settings → Setup Keys&lt;/strong&gt;, turn on&lt;br&gt;
&lt;strong&gt;Ephemeral Peers&lt;/strong&gt;, give it a group your access policies already allow, and store it as a repository secret.&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;Integration tests&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;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&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;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-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&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;Connect to the NetBird network&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;netbird&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;ankurk91/netbird-action@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;setup-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NETBIRD_SETUP_KEY }}&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 tests against the internal API&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "this runner is ${{ steps.netbird.outputs.netbird-ip }} on the network"&lt;/span&gt;
          &lt;span class="s"&gt;npm run test:integration&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;API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://internal-api.netbird.cloud&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that point the runner is a peer. It can reach your other peers by their NetBird address, or by name under&lt;br&gt;
&lt;code&gt;.netbird.cloud&lt;/code&gt;. There is no disconnect step to add on a hosted runner — the machine is destroyed when the job ends,&lt;br&gt;
and an ephemeral key takes care of the dashboard entry.&lt;/p&gt;

&lt;p&gt;Self-hosting the control plane only changes one input:&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ankurk91/netbird-action@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;setup-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NETBIRD_SETUP_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;management-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://netbird.example.com:443&lt;/span&gt;
          &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.78.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinning &lt;code&gt;version&lt;/code&gt; is worth doing for anything you care about reproducing. Left at &lt;code&gt;latest&lt;/code&gt;, the action installs&lt;br&gt;
whatever the newest client release is on the day the job runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  🌐 Connected is not the same as ready
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most, so it gets its own section.&lt;/p&gt;

&lt;p&gt;Joining the network and being able to resolve your private hostnames are two different milestones, and the gap between&lt;br&gt;
them is where flaky pipelines live. If the next step in your job reaches a service &lt;em&gt;by name&lt;/em&gt;, list those names and the&lt;br&gt;
action waits until they actually work before it hands over:&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ankurk91/netbird-action@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;setup-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NETBIRD_SETUP_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;dns-hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;postgres.netbird.cloud&lt;/span&gt;
            &lt;span class="s"&gt;internal-api.netbird.cloud&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a second failure this closes, and it is the nastier one. Plenty of names exist in both public DNS &lt;em&gt;and&lt;/em&gt; your&lt;br&gt;
private zone. Public DNS can answer first, and then your "internal" call quietly leaves the mesh and talks to the far&lt;br&gt;
side of the internet instead. Nothing errors. It surfaces an hour later as a confusing &lt;code&gt;403&lt;/code&gt; from an API that was&lt;br&gt;
supposed to be internal.&lt;/p&gt;

&lt;p&gt;So by default a name only counts as ready once it points &lt;strong&gt;inside&lt;/strong&gt; your network. If it answers with a public address,&lt;br&gt;
the action keeps waiting, and on timeout it tells you which name resolved to what. For a name that is genuinely&lt;br&gt;
supposed to answer publicly — one reached through an exit node, typically — turn the check off:&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;dns-hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
          &lt;span class="na"&gt;dns-require-private&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🛰️ Routing through an exit node
&lt;/h2&gt;

&lt;p&gt;An exit node is a peer in your network that other peers can route their internet traffic through. Select one, and the&lt;br&gt;
runner's outbound traffic leaves from that peer's public IP instead of GitHub's.&lt;/p&gt;

&lt;p&gt;That solves a problem plenty of teams run into: a third-party API, a payment gateway, a partner's SFTP server or a&lt;br&gt;
cloud provider's console that only accepts requests from an allowlisted IP. GitHub-hosted runners draw from a huge,&lt;br&gt;
shifting pool of addresses, so there is nothing useful to allowlist. Route the job through an exit node with a static&lt;br&gt;
IP and you have one address to hand over.&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;Connect through the exit node&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;ankurk91/netbird-action@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;setup-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NETBIRD_SETUP_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;exit-node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.NETBIRD_EXIT_NODE_ID }}&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;Call the partner API from a known IP&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;./scripts/sync-partner-data.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exit-node&lt;/code&gt; takes the network ID as it appears in your dashboard. The route has to be distributed to a group your setup&lt;br&gt;
key assigns to the runner, otherwise the peer never receives it — the action waits for it, selects it, and says so&lt;br&gt;
plainly if it never arrived.&lt;/p&gt;

&lt;p&gt;⚠️ One caveat worth internalising: an exit node carries &lt;code&gt;0.0.0.0/0&lt;/code&gt;, and that includes the runner's own connection back&lt;br&gt;
to GitHub. If the exit node cannot reach GitHub, the runner stops reporting and the job sits there until it times out.&lt;br&gt;
Try it on a &lt;code&gt;workflow_dispatch&lt;/code&gt; run before you put it behind a required check.&lt;/p&gt;

&lt;p&gt;Leave &lt;code&gt;exit-node&lt;/code&gt; unset and none of this applies. The runner joins the network and keeps its own egress.&lt;/p&gt;
&lt;h2&gt;
  
  
  🧹 Self-hosted runners
&lt;/h2&gt;

&lt;p&gt;Hosted runners are disposable, so there is nothing to clean up. Self-hosted ones are the exception: the machine&lt;br&gt;
outlives the job, and without teardown the next build on that runner inherits a network it never asked to join.&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ankurk91/netbird-action@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;setup-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NETBIRD_SETUP_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;cleanup&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;That deregisters the peer and undoes the install when the job ends, on success or failure. It is off by default so&lt;br&gt;
hosted runners do not pay for a teardown nobody needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Where this is useful
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deploying to private infrastructure.&lt;/strong&gt; SSH to a host with no public address, run migrations against a database in a
private subnet, push to an internal registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests against real internal services.&lt;/strong&gt; Rather than mocking the internal API, or exposing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stable egress IP for allowlisted third parties.&lt;/strong&gt; The exit node case above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid setups.&lt;/strong&gt; A runner in one cloud reaching services in another, without VPC peering or a site-to-site tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware you cannot move.&lt;/strong&gt; Anything already on your NetBird network is reachable from CI, lab machines included.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔗 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Action repository: &lt;a href="https://github.com/ankurk91/netbird-action" rel="noopener noreferrer"&gt;github.com/ankurk91/netbird-action&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Marketplace listing: &lt;a href="https://github.com/marketplace/actions/setup-netbird" rel="noopener noreferrer"&gt;Setup NetBird&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ankurk91/netbird-action/blob/main/TROUBLESHOOTING.md" rel="noopener noreferrer"&gt;Troubleshooting guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ankurk91/netbird-action/blob/main/HOW-IT-WORKS.md" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;, if you want the reasoning
behind any of the above&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.netbird.io" rel="noopener noreferrer"&gt;NetBird documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is MIT licensed, Linux only, and tested against real runs on every push. If you hit something the troubleshooting&lt;br&gt;
guide does not cover, open an issue. ⭐&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>networking</category>
      <category>ci</category>
    </item>
  </channel>
</rss>
