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 control plane that
distributes configuration and access policy, and helps two peers find each other through NAT, but it does not sit 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 control plane. 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 drops out of the dashboard on its own once the job is gone, so you are not left deleting dead runners by hand.
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, installed client versions that
were several releases behind, and was careless in the one place I could not afford carelessness: something that holds a
credential to my entire private network.
The behaviour that bothered me most was subtler than that, though. Most of them treat "the client reported success" as
"the job can now use the network". Those are not the same moment. A peer registers well before it can actually carry
traffic, and private DNS settles later still. If the next step in your job immediately reaches an internal service, you
get a pipeline that passes four times and fails the fifth, with nothing useful in the log.
So this action's whole job is to not hand control back until the network is genuinely usable โ and to say clearly what
was missing when it cannot.
๐ Getting started
The only required input is the setup key. Create one in the dashboard under Settings โ Setup Keys, turn on
Ephemeral Peers, give it a group your access 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@v5
- name: Connect to the NetBird network
id: netbird
uses: ankurk91/netbird-action@v2
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 your other peers by their NetBird address, or by name under
.netbird.cloud. There is no disconnect step to add on a hosted runner โ the machine is destroyed when the job ends,
and an ephemeral key takes care of the dashboard entry.
Self-hosting the control plane only changes one input:
- uses: ankurk91/netbird-action@v2
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.
๐ Connected is not the same as ready
This is the part I care about most, so it gets its own section.
Joining the network and being able to resolve your private hostnames are two different milestones, and the gap between
them is where flaky pipelines live. If the next step in your job reaches a service by name, list those names and the
action waits until they actually work before it hands over:
- uses: ankurk91/netbird-action@v2
with:
setup-key: ${{ secrets.NETBIRD_SETUP_KEY }}
dns-hostnames: |
postgres.netbird.cloud
internal-api.netbird.cloud
There is a second failure this closes, and it is the nastier one. Plenty of names exist in both public DNS and your
private zone. Public DNS can answer first, and then your "internal" call quietly leaves the mesh and talks to the far
side of the internet instead. Nothing errors. It surfaces an hour later as a confusing 403 from an API that was
supposed to be internal.
So by default a name only counts as ready once it points inside your network. If it answers with a public address,
the action keeps waiting, and on timeout it tells you which name resolved to what. For a name that is genuinely
supposed to answer publicly โ one reached through an exit node, typically โ turn the check off:
dns-hostnames: api.example.com
dns-require-private: false
๐ฐ๏ธ Routing through an exit node
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 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 hand over.
- name: Connect through the exit node
uses: ankurk91/netbird-action@v2
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 it appears in your dashboard. The route has to be distributed to a group your setup
key assigns to the runner, otherwise the peer never receives it โ the action waits for it, selects it, and says so
plainly if it never arrived.
โ ๏ธ One caveat worth internalising: an exit node carries 0.0.0.0/0, and that includes the runner's own connection back
to GitHub. If the exit node cannot reach GitHub, the runner stops reporting and the job sits there until it times out.
Try it on a workflow_dispatch run before you put it behind a required check.
Leave exit-node unset and none of this applies. The runner joins the network and keeps its own egress.
๐งน Self-hosted runners
Hosted runners are disposable, so there is nothing to clean up. Self-hosted ones are the exception: the machine
outlives the job, and without teardown the next build on that runner inherits a network it never asked to join.
- uses: ankurk91/netbird-action@v2
with:
setup-key: ${{ secrets.NETBIRD_SETUP_KEY }}
cleanup: true
That deregisters the peer and undoes the install when the job ends, on success or failure. It is off by default so
hosted runners do not pay for a teardown nobody needed.
๐ก Where this is useful
- Deploying to private infrastructure. SSH to a host with 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.
- Hybrid setups. A runner in one cloud reaching services in another, without VPC peering or a site-to-site tunnel.
- Hardware you cannot move. Anything already on your NetBird network is reachable from CI, lab machines included.
๐ Links
- Action repository: github.com/ankurk91/netbird-action
- Marketplace listing: Setup NetBird
- Troubleshooting guide
- How it works, if you want the reasoning behind any of the above
- NetBird documentation
It is MIT licensed, Linux only, and tested against real runs on every push. If you hit something the troubleshooting
guide does not cover, open an issue. โญ
Top comments (2)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.