IP Allowlisting for Fork-PR Preview Environments
GuideSeptember 29, 202610 min read

IP Allowlisting for Fork-PR Preview Environments

The Short Version

A fork pull request is, by definition, code nobody on the team has reviewed yet. Automatically deploying that code into a live, publicly reachable preview environment is genuinely useful (it lets a maintainer see the change running before spending review time on it) and genuinely risky (it is running untrusted code on real infrastructure with a public URL attached). IP allowlisting does not make that combination safe on its own, but it closes off the most obvious exposure: restricting who can even reach the running environment's URL to a known, trusted set of networks, so the fact that untrusted code is running does not automatically mean anyone on the internet can poke at it.

Why Fork PRs Are a Different Threat Model

A pull request opened from a branch on the same repository comes from someone who already has write access, meaning some prior trust decision (adding them as a collaborator) already happened. A fork PR can come from literally anyone with a GitHub account, including a first-time contributor a maintainer has never interacted with before. The code in that PR has had zero review by the time an automated pipeline deploys it into a preview environment, which means the environment needs to assume the code itself could be actively hostile, not just buggy.

This is not a hypothetical. It is the exact scenario that has produced real supply-chain incidents in open-source CI: a workflow that runs on pull_request_target (which grants access to the base repository's secrets and write permissions, even for a fork PR) combined with checking out and running the fork's own code, gives an attacker a path to exfiltrate secrets or push unauthorized changes, entirely through a PR they opened themselves.

The Trigger Choice That Actually Matters First

Before IP allowlisting is even relevant, the GitHub Actions trigger a fork-PR workflow uses determines how much damage is possible in the first place. pull_request runs with no access to repository secrets and read-only permissions by default, regardless of who opened the PR. pull_request_target runs with the base repository's full secret access and permissions, which is what makes it dangerous when combined with running the fork's own code.

YAML
1# Safe default for fork-PR preview environments
2on:
3  pull_request:
4    types: [opened, synchronize]
5
6jobs:
7  deploy-preview:
8    runs-on: ubuntu-latest
9    steps:
10      - uses: actions/checkout@v4
11      - run: |
12          bns environments create --from-path bunnyshell.yaml \
13            --name "pr-${{ github.event.number }}" \
14            --project <PROJECT_ID> --k8s <CLUSTER_ID> --deploy

The BUNNYSHELL_TOKEN (or equivalent deploy credential) used in this workflow should itself be scoped as narrowly as the platform allows, ideally to environment create/deploy/destroy permissions only, not an organization-wide admin token, since this token is now reachable from a workflow that, by design, runs in response to arbitrary fork PRs.

Restricting Who Can Reach the Deployed Environment

Once the environment is deployed, the second layer is restricting network access to it. The environment-wide restriction is the simplest and most common choice for fork-PR environments specifically, since it defaults every host to restricted unless explicitly marked otherwise:

YAML
1security:
2  access:
3    allowedIps:
4      - '203.0.113.0/24'   # Office / VPN egress range
5      - '198.51.100.5/32'  # CI system's static egress IP

Every host in the environment is unreachable from any IP outside these ranges, with one exception: any host explicitly marked public: true bypasses this restriction, which is useful for a single health-check endpoint an external uptime monitor needs to reach while everything else stays locked down.

YAML
1hosts:
2  - hostname: 'web-{{ env.base_domain }}'
3    servicePort: 3000
4    public: false   # Restricted to allowedIps (the default for fork-PR environments)
5
6  - hostname: 'health-{{ env.base_domain }}'
7    servicePort: 3000
8    path: /healthz
9    public: true    # Reachable regardless of allowedIps

Per-Host Restriction When Only One Component Needs It

The environment-wide restriction is the right default for a fork-PR environment specifically, since the whole point is limiting exposure of code nobody has reviewed. For environments where only one specific host needs tighter restriction than the rest (an admin panel inside an otherwise-open staging environment, for instance), a per-host nginx annotation restricts just that one hostname:

YAML
1hosts:
2  - hostname: 'admin-{{ env.base_domain }}'
3    path: /
4    servicePort: 8080
5    selfManagedDns: true
6    k8s:
7      ingress:
8        tlsSecretName: ''
9        annotations:
10          nginx.ingress.kubernetes.io/whitelist-source-range: '203.0.113.0/24,198.51.100.5/32'

For fork-PR environments where the entire environment is the risk surface, not just one component, the environment-wide security.access.allowedIps setting is the better default: it fails closed for every new component added later, whereas a per-host annotation has to be remembered and re-applied to every new host added to the template.

The CI-Runner IP Problem

A real, practical wrinkle: if the workflow that needs to run E2E tests against the fork-PR environment runs on a GitHub-hosted runner, that runner does not have a small, stable IP range suitable for an allowlist. GitHub's hosted runner pool spans a large and changing set of cloud IP ranges, which makes "just allowlist the CI runner's IP" unreliable in practice for GitHub-hosted runners specifically.

Two practical options handle this: routing the test-runner traffic through a self-hosted runner with a fixed egress IP (which can then be added to allowedIps reliably), or routing through a small fixed-IP proxy or NAT gateway the CI job tunnels through specifically to reach IP-restricted environments. Both add a small amount of infrastructure, but avoid the alternative, which is either giving up on IP restriction for this specific use case or allowlisting a CIDR range broad enough to be close to meaningless.

Trusted Proxies and Real Client IP Detection

One subtlety worth getting right: if traffic to the environment passes through a CDN, load balancer, or reverse proxy before reaching the ingress controller, the IP the ingress sees by default may be the proxy's IP, not the actual client's, which breaks allowlisting entirely (every request appears to come from the same proxy IP, either passing or failing the allowlist as a block regardless of the real requester). The realIpTrustedProxies setting addresses this by telling the ingress which proxy IPs to trust for forwarding the real client IP header:

YAML
1security:
2  access:
3    allowedIps:
4      - '203.0.113.0/24'
5    realIpTrustedProxies:
6      - '10.0.0.0/8'  # Internal CIDR range of the trusted proxy/CDN layer

Skipping this setting when traffic genuinely does pass through a trusted proxy is a common way allowlisting silently stops working correctly: either everything gets blocked (because the proxy's IP itself is not on the allowlist) or everything gets through (if the ingress falls back to trusting the forwarded header blindly without verifying the proxy).

Combining This With the Rest of the Threat Model

IP allowlisting closes off one specific exposure: who can reach the environment's network endpoint at all. It does nothing about what happens once a request from an allowlisted IP interacts with genuinely untrusted code running inside that environment, and it does nothing to prevent that code from making outbound connections to other infrastructure if network isolation between environments is not also in place. See Securing E2E Test Environments for the fuller threat model this fits into, including the network isolation and secrets-handling controls that need to work alongside IP restriction, not instead of it, for fork-PR automation to be a defensible pattern rather than just a convenient one.

A Reasonable Default Policy

For a repository that accepts external contributions and auto-deploys fork PRs into preview environments, a reasonable default looks like: pull_request trigger only, never pull_request_target, for any workflow that checks out and runs the fork's own code; a scoped deploy credential with no access beyond creating and destroying environments; environment-wide allowedIps restricted to the team's own office or VPN egress range plus any CI systems that genuinely need access through a fixed-IP path; and network isolation preventing the fork-PR environment from reaching any other environment or shared internal service. None of these individually is sufficient. Together, they make "automatically deploy code from strangers" a reasonable thing to do instead of a standing incident waiting to happen.

Pairing Allowlisting With a First-Time-Contributor Approval Gate

IP allowlisting controls who can reach a deployed environment. It says nothing about whether that environment should have been deployed automatically in the first place. GitHub's own "require approval for first-time contributors" workflow setting adds a gate before this question: a PR from someone who has never had a workflow run in the repository before requires a maintainer to explicitly click "approve and run workflow" before any CI, including the deploy step, executes at all.

Combining both controls produces a meaningfully stronger default than either alone: a first-time contributor's fork PR does not even trigger a preview deploy until a maintainer has at least glanced at the diff and clicked approve, and once it does deploy, the resulting environment is still restricted to the team's own allowlisted IP ranges rather than being reachable by anyone who guesses or is handed the URL. A trusted, repeat contributor's fork PRs can skip the approval gate (since GitHub tracks this per-contributor once they have a merged PR or explicit collaborator status) while still going through the same IP-restricted preview environment every fork PR gets.

Logging and Alerting on Blocked Requests

An allowlist that silently drops requests from outside its range provides the intended protection, but a team debugging "why can't I reach the preview URL" benefits from actually being able to see denied requests, not just experience them. The nginx ingress controller logs a 403 for requests outside the configured whitelist-source-range, and shipping ingress access logs to wherever the team already aggregates logs turns this into a debuggable, observable control rather than an opaque one:

Bash
1$ kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail 100 | grep " 403 "
2
3203.0.113.99 - - [29/Sep/2026:14:12:03] "GET /" 403 146 "-" "Mozilla/5.0"

Beyond debugging, a spike in denied requests against a specific fork-PR environment, particularly one that correlates with the PR being newly opened, is itself a useful weak signal worth a maintainer's attention: it can mean someone shared the preview link outside the intended audience, or it can just mean a teammate is trying to reach it from an unregistered network and needs their IP added. Either way, having the log line to look at turns a mystery into a two-minute check.

What IP Allowlisting Does Not Protect Against

It is worth being precise about the limits of this control, since overselling it invites a false sense of security. IP allowlisting restricts network reachability, not what happens once a request from an allowlisted IP arrives. It does nothing to stop a genuinely malicious fork PR's code from behaving badly once a legitimate, allowlisted reviewer opens the environment and interacts with it, since the reviewer's own browser is now the one making requests into the untrusted environment. It also does nothing to prevent the environment's own outbound network traffic from reaching other infrastructure, which is exactly what network isolation between environments is for, and does nothing about a compromised machine that happens to sit on an allowlisted network, which is a real if less common scenario worth keeping in mind for anything genuinely high-stakes.

None of this is an argument against IP allowlisting, which remains a straightforward, high-value control for the specific problem it solves. It is an argument for treating it as one layer in a set, not a complete answer on its own, which is the same framing Securing E2E Test Environments takes for the fork-PR threat model as a whole.

A Complete Example

Putting the pieces together, here is what a reasonably defensive fork-PR preview configuration looks like end to end:

YAML
1kind: Environment
2name: pr-preview
3type: ephemeral
4
5security:
6  access:
7    allowedIps:
8      - '203.0.113.0/24'   # Team office / VPN range
9      - '198.51.100.5/32'  # Self-hosted CI runner, fixed egress IP
10    realIpTrustedProxies:
11      - '10.0.0.0/8'
12
13components:
14  - kind: Application
15    name: web
16    hosts:
17      - hostname: 'web-{{ env.base_domain }}'
18        servicePort: 3000
19        public: false
20
21  - kind: Application
22    name: web-health
23    hosts:
24      - hostname: 'health-{{ env.base_domain }}'
25        servicePort: 3000
26        path: /healthz
27        public: true

Paired with a pull_request-triggered (not pull_request_target) workflow using a narrowly scoped deploy token, and GitHub's first-time-contributor approval gate enabled at the repository level, this is a defensible default for automatically previewing fork PRs without either blocking external contributions entirely or leaving them fully exposed.

For the broader security threat model fork-PR environments sit inside, including secrets handling and network isolation, see Securing E2E Test Environments. For the equivalent IP-restriction pattern applied to long-lived staging rather than ephemeral fork-PR environments, see IP-Restricting Staging Access.

Checklist

  • Fork-PR workflows use pull_request, never pull_request_target, for any step that runs the fork's own code
  • The deploy credential used by fork-PR workflows is scoped narrowly, not an admin-level token
  • Environment-wide allowedIps is set for fork-PR environments, with public: true only on the specific hosts that genuinely need open access
  • realIpTrustedProxies is configured if traffic passes through a CDN or proxy layer before reaching the ingress
  • CI systems that need to reach IP-restricted environments route through a fixed-IP path, not a GitHub-hosted runner's unpredictable IP range
✦✦

Make it safe to auto-deploy code from strangers.

14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.

Frequently Asked Questions

Why is a fork PR specifically riskier than a PR from a branch on the same repository?

A branch PR is opened by someone with write access to the repository, whose code has already passed whatever access control got them that permission. A fork PR can be opened by anyone, including a first-time contributor, meaning the code deploying into the preview environment has had zero prior review or trust established.

Does IP allowlisting alone make it safe to deploy fork-PR code automatically?

No, it is one layer among several. Network isolation (so a compromised fork-PR environment cannot reach other environments), scoped and encrypted secrets, and IP restriction on who can reach the environment's URL all need to work together. IP allowlisting specifically limits who can even attempt to interact with the running environment, which matters a great deal but does not substitute for the other controls.

Should the CI job that deploys a fork PR have production secrets available to it?

No. GitHub Actions' pull_request_target trigger runs with the base repository's secrets and permissions even for a fork PR, which is exactly the combination that has caused real supply-chain incidents. The safer default is the plain pull_request trigger, which runs with no access to repository secrets, paired with a separate, tightly scoped credential used only for creating the disposable preview environment itself.

Can I restrict a fork-PR environment to just my organization's office IPs?

Yes, using the environment-wide allowedIps restriction with your organization's static egress IP ranges. This is the most common real-world configuration: reviewers and CI systems on the allowlist can reach the environment, and anyone else, including the fork PR's own author outside a review context, cannot.

What about GitHub-hosted CI runners? Can I allowlist their IPs specifically?

Generally not reliably. GitHub-hosted runners do not use a small, stable IP range suitable for an allowlist, so teams that need CI itself (not just human reviewers) to reach an IP-restricted environment usually route that traffic through a self-hosted runner or a fixed-IP proxy rather than trying to allowlist GitHub's own runner pool.