Hey everyone,
I've been deep in the weeds lately setting up a robust, company-wide security policy for our development infrastructure, and one of the key requirements was ensuring all outbound traffic from our GitHub Actions runners—including those pesky self-hosted ones—goes through our NordLayer gateways. This wasn't just about securing secrets; it was about guaranteeing all CI/CD traffic originates from known, static IPs for our allow-listed external services (like some legacy deployment targets and internal package registries).
The out-of-the-box NordLayer apps are great for user devices, but for ephemeral runners, we needed something scriptable and headless. After a lot of trial and error, I landed on a working solution using the NordLayer Linux CLI and some GitHub Actions YAML-fu.
The core idea is to install and authenticate the NordLayer CLI on the runner, then force all traffic through the designated gateway. Here's the step-by-step approach, broken down into a reusable composite action or a job template.
**First, you need to prepare a service token.**
Head to the NordLayer Admin Panel -> Service tokens -> Create token. This token is crucial for headless authentication. Store it as a GitHub Actions secret, let's say `NORDLAYER_SERVICE_TOKEN`.
**Here's the essence of the setup for a Linux runner (Ubuntu) job:**
```yaml
jobs:
build-behind-nordlayer:
runs-on: ubuntu-latest
steps:
- name: Install NordLayer CLI
run: |
curl -sSL https://downloads.nordlayer.com/linux/latest/deb/ -o nordlayer.deb
sudo dpkg -i nordlayer.deb || sudo apt-get update && sudo apt-get install -f -y
- name: Authenticate with Service Token
run: nordlayer login --token ${{ secrets.NORDLAYER_SERVICE_TOKEN }}
- name: Connect to Specific Gateway (Optional)
run: nordlayer connect --group-id your_team_group_id --server-id your_preferred_server_id
# If you just need 'any' gateway in your team, you can often skip specifying a server-id.
- name: Verify Connection and IP
run: |
nordlayer status
curl -s ifconfig.me
echo " - Runner external IP is now a NordLayer IP."
- name: Your Build Steps
run: |
# Your normal build, test, and deploy commands
# All outbound traffic is now routed via NordLayer
- name: Disconnect (Important!)
if: always()
run: nordlayer disconnect
```
**Key Pitfalls & Workarounds:**
* **Self-hosted Runners:** For self-hosted runners, you might want to bake the CLI installation into the runner image to save time. However, authentication should *always* happen fresh in each job using the ephemeral service token for security.
* **DNS Leaks:** The default setup might still use your runner's DNS. To force DNS through NordLayer's secure DNS, you might need to modify `/etc/resolv.conf` during the job (and revert it after). This gets tricky and can break the runner's ability to resolve GitHub's own domains. Test extensively.
* **"Always On" Policy:** We use the NordLayer Team Gateway feature. By assigning our team's `group-id` in the connect command, any runner automatically uses the gateway our network admins configured, enforcing our security policies.
* **Cleanup:** The `if: always()` on the disconnect step is **critical**. Even if a job fails, you must disconnect the runner from NordLayer so it doesn't hold a connection slot and can return to a clean state.
This approach has given us fantastic visibility and control. Our security team now sees all CI traffic coming from a handful of known NordLayer IPs, and we've been able to lock down our external services accordingly. It adds a few seconds to job startup, but the trade-off for compliance is more than worth it.
Has anyone else tackled something similar? I'm particularly curious if there are smoother patterns for the DNS aspect on GitHub-hosted runners.
api first
api first
Absolutely on point about needing a service token. That's the correct auth method for automation, but I need to ask about the rate limiting and concurrency costs for your approach. When you scale to hundreds of simultaneous self-hosted runners, each establishing its own NordLayer tunnel, have you quantified the potential service token usage costs or hit any API throttles from NordLayer's side? Their pricing model for heavy, automated use can get surprising.
CostCutter
Installing the CLI on ephemeral runners feels heavy. Why not bake the authenticated client and its config into your runner image once? Then your job step is just `nordlayer connect --group YourGatewayGroup`. Saves seconds per job and removes the token fetch from the workflow log.
Your fancy demo doesn't scale.
That's the ideal for truly ephemeral runners. The trade-off is image management. Now you've tied your runner image updates to your NordLayer config updates. If the CLI gets a critical security patch or you need to rotate service tokens, you have to rebuild and redeploy every runner image, not just update a workflow.
It works if your image pipeline is solid, but adds ops overhead. For us, the extra few seconds per job was cheaper than managing that coupling.
Show me the bill
This is exactly the trade-off nobody talks about. Pre-baking the config feels cleaner until you need to rotate that embedded token. Then it's an incident, not a workflow update.
It couples your immutable infrastructure to a credential that absolutely must be mutable. That's backwards.
The "few seconds per job" isn't just about cost, it's about keeping the security boundary in the pipeline where you can audit and change it without a forklift upgrade.
Just my 2 cents
Yeah, that's a great point about the incident vs. update. It pushes the risk way down the line.
I'm still new at this, but that makes me think - couldn't you pre-bake the CLI but *not* the token? Fetch the token at runtime from a secret store and store the config in a runner volume. That might split the difference on overhead.
Is that a common pattern, or does it just add more moving parts?
Service tokens are definitely the way to go for this. Don't even think about trying to script a regular user login, it's a world of pain.
One gotcha I learned the hard way: when you create that token, scope it down to *only* the gateway groups your runners need. If your org has other groups for finance or whatever, lock it out. It prevents a compromised runner from hopping into a completely different network segment.
Preparing the service token first is the correct starting point. When you generate it, also set an explicit expiration date in the Admin Panel, even if far in the future. This forces a periodic review and avoids creating permanent, forgotten credentials. I'd recommend documenting this expiration in the same place you store the secret reference for your workflows.
CPU cycles matter
Spot on about starting with the service token. I'd add that you need to be meticulous about storing it as a GitHub organization secret, not a repository secret, especially if you have multiple repos using the same runner pools. A repo-level secret means anyone with write access to that repo could potentially expose it in a workflow log with a simple `echo` step - an org secret adds a crucial layer of access control.
Also, when you generate the token, pay close attention to the permission scopes. The default might be too permissive. For a runner, you likely only need 'Gateway: Connect' and maybe 'Gateway: Read' to list groups. Locking it down reduces the blast radius immediately.
Implementation is 80% process, 20% tool.
Thanks for sharing this. I'm still getting my head around self-hosted runners and network controls, so this is really helpful.
One question about the "force all traffic through the designated gateway" part. What happens if the NordLayer tunnel drops mid-job? Do you have a retry or health check in your YAML, or does the whole workflow fail? I've been burned by VPN flakiness on my own dev machine and can't imagine relying on it for CI without some fallback.
Also, you mentioned "YAML-fu" - any chance you could share a minimal example of the composite action? I'm trying to avoid reinventing the wheel.
Yes, 100% on the org secret vs repo secret. I've seen a team accidentally echo a repo secret in a debug step because they assumed it was safe. Using org secrets means even a repo admin can't accidentally expose it without org-level access.
For the scopes, you can often go even leaner than 'Gateway: Read' if you know your exact group ID upfront. The CLI can accept a group ID flag directly, so the runner only needs the bare minimum 'connect' permission. Every little bit helps!
Beta tester at heart
That's a good point about the minimal scopes. If you hardcode the group ID in the workflow, does that create another coupling? If someone moves or renames the gateway group in NordLayer's admin panel, the workflows break until you update the IDs everywhere.
Do you version control those group IDs somewhere, or is that just part of the manual config update when it happens?
That's a reasonable compromise and it's actually closer to what I've seen work in production at scale. Pre-baking the CLI binary into the runner image is fine because the CLI itself doesn't change often and doesn't carry secrets. Fetching the token at runtime from a secret store (GitHub Actions secrets, Vault, or whatever your org uses) keeps the mutable credential lifecycle out of the base image. So you get the startup speed of a pre-installed binary without the "incident waiting to happen" that user831 described.
The runner volume angle is the part that can trip you up. If your runners are ephemeral (e.g., auto-scaling on EC2 or Kubernetes), the volume is gone after the job. You can't rely on it persisting a cached config across runs. In that case you need to either re-authenticate every job (which is fine, it's a lightweight API call) or mount a persistent volume from a network store. I've seen teams do the latter, but then you're back to managing a shared mutable state, which introduces its own set of locking and consistency headaches.
My usual approach is a composite action that does:
```
- name: Install NordLayer CLI (if not present)
run: |
if ! command -v nordlayer &> /dev/null; then
sudo apt-get update && sudo apt-get install -y nordlayer-cli
fi
shell: bash
- name: Authenticate with service token
run: |
nordlayer auth service-token ${{ secrets.NORDLAYER_SERVICE_TOKEN }}
shell: bash
- name: Connect to gateway
run: |
nordlayer gateway connect --group-id ${{ vars.NORDLAYER_GROUP_ID }}
shell: bash
```
The `if` check on the CLI install means the first job on a cold runner pays the install cost, but subsequent jobs on the same runner skip it. That's a minor overhead compared to the tunnel establishment itself, which is usually 2-5 seconds.
One thing I'd add to your split-difference idea: you could cache the authenticated session token (the one the CLI writes to disk after `auth service-token`) in a runner volume if you're sure the runner is long-lived. But even then, the token expires, so you'd need a refresh mechanism. I've found it simpler to just call `auth service-token` every job - it's a single HTTP call, not a full login flow, and the cost is negligible.
What's your runner lifespan? That really determines whether the volume caching is worth the complexity.
Excellent starting point with the service token. To expand on that, the exact CLI commands you need to configure and launch the connection are surprisingly minimal once you have that token.
A common hiccup is that the CLI doesn't always add the required routes automatically. You might need to explicitly specify the `--allow` or `--deny` arguments for split tunneling, or even force the default route. For a "force all traffic" scenario, I've found this pattern works reliably:
```bash
nordlayer connect --token ${{ secrets.NORDLAYER_SERVICE_TOKEN }} --group-id --force
```
The `--force` flag is key - it ensures the connection succeeds even if there are existing conflicting routes, which is common on fresh runners. Without it, the job can hang waiting for a "clean" network state that never arrives.
Prod is the only environment that matters.