<?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>I Rewrote My Electron App in Tauri — and Claude Did 100% of the Work in Under 24 Hours 🚀</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 MB&lt;/strong&gt; Linux installer instead of a bundled Chromium.&lt;/li&gt;
&lt;li&gt;🧪 &lt;strong&gt;Status:&lt;/strong&gt; pre-release. Fun project. Stable version coming after real-world testing.&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.&lt;/p&gt;

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

&lt;p&gt;Last week 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;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;38 commits. ~3,600 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;~80–100 MB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~3 MB&lt;/strong&gt; 🤯&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;The size difference is the headline number, 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 — WebKitGTK on Linux behaves differently from Chromium, and you find that out the hard way. Which is exactly what the testing phase is for. 🧪&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;Launch at login&lt;/strong&gt; — optional, plus "start hidden in the tray."&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;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;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;Says when it can't reach Chat&lt;/strong&gt; — instead of a blank web engine error page.&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;/ul&gt;

&lt;p&gt;No analytics. No auto-updater installing things behind your back — it just asks GitHub once 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;
  
  
  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 still pre-release.&lt;/strong&gt; &lt;code&gt;v0.0.1&lt;/code&gt;. Linux is where it's been tested most; macOS and Windows builds exist and need real users on real machines.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;A stable release is coming&lt;/strong&gt; once it's been properly tested in the wild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Google Chat on the desktop and you're feeling brave, try it and tell me what breaks. That's genuinely the most useful thing right now. 🐛&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.&lt;/p&gt;

&lt;p&gt;That's not a flex about me. It's a data point about where tooling is in 2026. The hard part of this project was never typing the code — it was &lt;em&gt;knowing what the app should do&lt;/em&gt; and &lt;em&gt;checking it against reality&lt;/em&gt;. AI took the first job. The second one is still ours. 🤝&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):&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 pre-release:&lt;/strong&gt; &lt;a href="https://github.com/ankurk91/google-chat-tauri/releases" rel="noopener noreferrer"&gt;https://github.com/ankurk91/google-chat-tauri/releases&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 management service that&lt;br&gt;
distributes configuration and an access policy, plus a signal service that helps two peers find each other through NAT,&lt;br&gt;
but neither of them sits in 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 management and signal services. 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 is removed automatically once the job ends, so your dashboard does not slowly fill with
dead runners.&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, pinned client versions that were&lt;br&gt;
several releases behind, and cut corners I was not comfortable with in something that holds a credential to my network.&lt;/p&gt;

&lt;p&gt;The two that bothered me most: passing the setup key as a &lt;code&gt;--setup-key&lt;/code&gt; command line flag, where it lands in the&lt;br&gt;
process list for anything else on the machine to read, and treating &lt;code&gt;netbird up&lt;/code&gt; returning as "connected". It is not.&lt;br&gt;
That command returns once the daemon has accepted the login, which is earlier than the peer being able to carry&lt;br&gt;
traffic. The signal connection and the network map arrive after. If the next step in your job immediately curls an&lt;br&gt;
internal service, you get a flaky pipeline and no idea why.&lt;/p&gt;

&lt;p&gt;So the action waits for &lt;code&gt;Management: Connected&lt;/code&gt; and &lt;code&gt;Signal: Connected&lt;/code&gt; before handing control back, writes the key to&lt;br&gt;
a temporary file instead of argv, and fails with an actionable message instead of hanging.&lt;/p&gt;
&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;p&gt;The only required input is the setup key. Create one in the dashboard under &lt;strong&gt;Settings -&amp;gt; Setup Keys&lt;/strong&gt;, turn on&lt;br&gt;
&lt;strong&gt;Ephemeral&lt;/strong&gt;, give it a group your 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@v7&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@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;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 other peers by their NetBird IP, or by name under&lt;br&gt;
&lt;code&gt;.netbird.cloud&lt;/code&gt; if you have a nameserver group covering it. There is no disconnect step to add: with an ephemeral key&lt;br&gt;
the peer disappears on its own once the runner is destroyed.&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@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;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;
  
  
  The exit node
&lt;/h2&gt;

&lt;p&gt;This is the optional feature I get the most questions about, so it is worth explaining properly.&lt;/p&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 have 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 give them.&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@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;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 the dashboard and &lt;code&gt;netbird routes ls&lt;/code&gt; show it. The route has to be distributed to&lt;br&gt;
a group the setup key assigns to the runner, otherwise the peer never sees it. The action waits for the route to&lt;br&gt;
arrive, then selects it, and tells you plainly if it never showed up rather than failing on the next step.&lt;/p&gt;

&lt;p&gt;One thing to keep in mind, and the README says this in a warning box too: an exit node carries &lt;code&gt;0.0.0.0/0&lt;/code&gt;. That&lt;br&gt;
includes the runner's connection back to GitHub. If the exit node cannot reach GitHub, the job stops reporting and sits&lt;br&gt;
there until it times out. Test it on a &lt;code&gt;workflow_dispatch&lt;/code&gt; run before you put it in 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 simply joins the network and keeps its own egress.&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 that has 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;Self-hosted runners in a hybrid setup.&lt;/strong&gt; A runner in one cloud reaching services in another, without peering
or a site-to-site tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reaching a colleague's or a lab machine.&lt;/strong&gt; Anything already on your NetBird network is reachable from CI, which
includes hardware you cannot move.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Details worth knowing
&lt;/h2&gt;

&lt;p&gt;A few decisions in the action that are not obvious from the input list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The setup key is written to a temporary file and passed with &lt;code&gt;--setup-key-file&lt;/code&gt;, never on the command line, and it
is masked in the log even if someone passes it from &lt;code&gt;vars&lt;/code&gt; by mistake.&lt;/li&gt;
&lt;li&gt;The install uses the release binary rather than the apt package, which avoids adding a repository and an
&lt;code&gt;apt-get update&lt;/code&gt;, and covers the &lt;code&gt;-arm&lt;/code&gt; runners on the same path.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;github-token&lt;/code&gt; is sent only when you pin a version, because that path resolves the tag through the GitHub API and
hosted runners share egress addresses. &lt;code&gt;latest&lt;/code&gt; reads NetBird's CDN and gains nothing from a token, so it never
sees one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;diagnostics&lt;/code&gt; is off by default. Turned on it prints &lt;code&gt;netbird status -d&lt;/code&gt;, the routes and the public IP before and
after connecting, which is exactly what you want when a connection fails and exactly what you do not want sitting in
a job log the rest of the time. Failures print an anonymised status either way.&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://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 and tested against real runs on every push. If you hit something the troubleshooting guide does not&lt;br&gt;
cover, open an issue.&lt;/p&gt;

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