Sooner or later a CI job needs to reach something that is not on the public internet. A staging database, an internal
container registry, a deploy target sitting behind a firewall, an integration test suite that talks to a service you
have no intention of exposing.
The usual answers are not great. You can allowlist GitHub's egress ranges, which are enormous, change regularly, and
effectively mean "allow anyone's CI job". You can move to self-hosted runners and inherit the maintenance. Or you can
run some VPN client in the job and hope the setup survives contact with a fresh container every time.
I went with the third option, and ended up writing the action I wanted:
ankurk91/netbird-action.
A short word on NetBird
NetBird is an overlay network built on WireGuard. Every machine you enrol becomes a peer with a
stable address in the 100.64.0.0/10 range, and peers talk to each other directly. There is a management service that
distributes configuration and an access policy, plus a signal service that helps two peers find each other through NAT,
but neither of them sits in the data path once a tunnel is up.
That last part is the real difference from a traditional VPN. A classic setup is hub and spoke: a concentrator with a
public IP and an open port, and everything routed through it. That box is a bottleneck, a single point of failure, and
the one thing on your perimeter that absolutely must never be misconfigured. It also tends to hand out access by
subnet, so being on the VPN means being on the network.
NetBird works the other way around:
- No inbound port anywhere. Peers dial out to the management and signal services. Nothing on your side needs a public listener.
- Direct tunnels. 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.
- Access by identity, not by subnet. 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.
- Setup keys. Machines enrol non-interactively with a key, which is exactly what CI needs. Mark the key ephemeral and the peer is removed automatically once the job ends, so your dashboard does not slowly fill with dead runners.
There is a free tier that covers a small team, and you can self-host the whole control plane if you would rather.
Why another action
I looked for an existing one first. What I found had not been touched in a long time, pinned client versions that were
several releases behind, and cut corners I was not comfortable with in something that holds a credential to my network.
The two that bothered me most: passing the setup key as a --setup-key command line flag, where it lands in the
process list for anything else on the machine to read, and treating netbird up returning as "connected". It is not.
That command returns once the daemon has accepted the login, which is earlier than the peer being able to carry
traffic. The signal connection and the network map arrive after. If the next step in your job immediately curls an
internal service, you get a flaky pipeline and no idea why.
So the action waits for Management: Connected and Signal: Connected before handing control back, writes the key to
a temporary file instead of argv, and fails with an actionable message instead of hanging.
Usage
The only required input is the setup key. Create one in the dashboard under Settings -> Setup Keys, turn on
Ephemeral, give it a group your policies already allow, and store it as a repository secret.
name: Integration tests
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Connect to the NetBird network
id: netbird
uses: ankurk91/netbird-action@v1
with:
setup-key: ${{ secrets.NETBIRD_SETUP_KEY }}
- name: Run tests against the internal API
run: |
echo "this runner is ${{ steps.netbird.outputs.netbird-ip }} on the network"
npm run test:integration
env:
API_URL: http://internal-api.netbird.cloud
From that point the runner is a peer. It can reach other peers by their NetBird IP, or by name under
.netbird.cloud if you have a nameserver group covering it. There is no disconnect step to add: with an ephemeral key
the peer disappears on its own once the runner is destroyed.
Self-hosting the control plane only changes one input:
- uses: ankurk91/netbird-action@v1
with:
setup-key: ${{ secrets.NETBIRD_SETUP_KEY }}
management-url: https://netbird.example.com:443
version: 0.78.1
Pinning version is worth doing for anything you care about reproducing. Left at latest, the action installs
whatever the newest client release is on the day the job runs.
The exit node
This is the optional feature I get the most questions about, so it is worth explaining properly.
An exit node is a peer in your network that other peers can route their internet traffic through. Select one, and the
runner's outbound traffic leaves from that peer's public IP instead of GitHub's.
That solves a problem plenty of teams have run into: a third-party API, a payment gateway, a partner's SFTP server or a
cloud provider's console that only accepts requests from an allowlisted IP. GitHub-hosted runners draw from a huge,
shifting pool of addresses, so there is nothing useful to allowlist. Route the job through an exit node with a static
IP and you have one address to give them.
- name: Connect through the exit node
uses: ankurk91/netbird-action@v1
with:
setup-key: ${{ secrets.NETBIRD_SETUP_KEY }}
exit-node: ${{ vars.NETBIRD_EXIT_NODE_ID }}
- name: Call the partner API from a known IP
run: ./scripts/sync-partner-data.sh
exit-node takes the network ID as the dashboard and netbird routes ls show it. The route has to be distributed to
a group the setup key assigns to the runner, otherwise the peer never sees it. The action waits for the route to
arrive, then selects it, and tells you plainly if it never showed up rather than failing on the next step.
One thing to keep in mind, and the README says this in a warning box too: an exit node carries 0.0.0.0/0. That
includes the runner's connection back to GitHub. If the exit node cannot reach GitHub, the job stops reporting and sits
there until it times out. Test it on a workflow_dispatch run before you put it in a required check.
Leave exit-node unset and none of this applies. The runner simply joins the network and keeps its own egress.
Where this is useful
- Deploying to private infrastructure. SSH to a host that has no public address, run migrations against a database in a private subnet, push to an internal registry.
- Integration tests against real internal services. Rather than mocking the internal API, or exposing it.
- A stable egress IP for allowlisted third parties. The exit node case above.
- Self-hosted runners in a hybrid setup. A runner in one cloud reaching services in another, without peering or a site-to-site tunnel.
- Reaching a colleague's or a lab machine. Anything already on your NetBird network is reachable from CI, which includes hardware you cannot move.
Details worth knowing
A few decisions in the action that are not obvious from the input list:
- The setup key is written to a temporary file and passed with
--setup-key-file, never on the command line, and it is masked in the log even if someone passes it fromvarsby mistake. - The install uses the release binary rather than the apt package, which avoids adding a repository and an
apt-get update, and covers the-armrunners on the same path. - A
github-tokenis sent only when you pin a version, because that path resolves the tag through the GitHub API and hosted runners share egress addresses.latestreads NetBird's CDN and gains nothing from a token, so it never sees one. -
diagnosticsis off by default. Turned on it printsnetbird status -d, 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.
Links
- Action repository: github.com/ankurk91/netbird-action
- Marketplace listing: Setup NetBird
- Troubleshooting guide
- NetBird documentation
It is MIT licensed and tested against real runs on every push. If you hit something the troubleshooting guide does not
cover, open an issue.
Top comments (0)