IP-Restricting Staging Access So Only Your Team Can See It
Public By Default, and Why That's the Right Starting Point
Bunnyshell environments get a real, publicly resolvable HTTPS URL the moment they deploy. This is a deliberate default, not an oversight — the entire value proposition of ephemeral PR environments depends on a reviewer being able to click a link and see the running app, and a CI job being able to reach the environment without VPN configuration or firewall exceptions per-run.
But "reachable by anyone with the link" is a real exposure for environments that hold anything sensitive — realistic customer-like test data, internal tooling, a feature under embargo before a public launch date. The fix is not to make environments private by default (which would break the CI/reviewer convenience that makes them useful in the first place) — it's to restrict specific environments or hosts to specific IP ranges, explicitly, when the environment warrants it.
Per-Host IP Restriction
The mechanism is the standard NGINX ingress controller source-range allowlist, configured as an annotation on the specific host you want restricted:
1hosts:
2 - hostname: 'staging-{{ env.base_domain }}'
3 path: /
4 servicePort: 8080
5 k8s:
6 ingress:
7 annotations:
8 nginx.ingress.kubernetes.io/whitelist-source-range: '203.0.113.0/24,198.51.100.14/32'Requests from outside 203.0.113.0/24 and 198.51.100.14 receive a rejection directly at the ingress layer — your application never sees the request at all, which matters because it means an application-level bug or misconfiguration cannot accidentally leak past this control. The restriction is enforced by the infrastructure layer, not something your code has to remember to check.
Typical CIDR ranges to include:
- Your office or VPN's egress IP range
- Your CI provider's published runner IP ranges (for automated E2E tests to reach the environment)
- Specific individual reviewer IPs, if remote team members don't route through a shared VPN
Environment-Wide Restriction With Explicit Public Opt-In
For environments with multiple hosts where most should be restricted and only a specific one (a public marketing preview page, say) should remain open, the cleaner pattern is restricting at the environment level and having individual hosts explicitly opt out of the restriction:
1kind: Environment
2name: staging
3components:
4 - kind: Application
5 name: internal-admin
6 hosts:
7 - hostname: 'admin-{{ env.base_domain }}'
8 # inherits the environment-wide restriction — not public
9
10 - kind: Application
11 name: public-docs
12 hosts:
13 - hostname: 'docs-{{ env.base_domain }}'
14 public: true # explicitly opts out of the environment-wide restrictionThis "closed by default, opt into public" ordering is deliberately the opposite of "open by default, opt into restricted." The practical reason: forgetting to restrict something you meant to restrict is a security incident; forgetting to un-restrict something you meant to leave public is, at worst, an internal Slack message asking why a link doesn't load. The failure modes are not symmetric, so the default shouldn't be either.
The CI IP Problem, Solved Properly
The most common way IP restriction breaks a working pipeline is CI runners using ephemeral, changing IPs — an allowlist entry for "whatever IP the last successful run happened to use" silently stops working the next time the CI provider assigns a different one.
Two durable fixes:
Allowlist your CI provider's published IP ranges. GitHub Actions, GitLab CI, and CircleCI all publish the IP ranges their hosted runners use. These change occasionally (worth a scheduled review, not a one-time setup) but are far more stable than allowlisting per-run.
Route CI traffic through a fixed egress point. A NAT gateway, a self-hosted runner with a static IP, or a proxy specifically for E2E test traffic turns a changing-IP problem into a single, stable IP to allowlist — and gives you one place to audit or revoke access if needed later.
Whichever approach you pick, the goal is the same: the allowlist should be maintained as a deliberate, documented list of who this environment should be reachable by, not reverse-engineered from whatever happened to work last time someone's pipeline ran.
IP Restriction Is Not Authentication
Worth stating plainly because it's a common conflation: IP allowlisting restricts network reachability, not identity. It answers "can a request from this address even reach the ingress," not "is this specific person authorized to see this data." An environment behind IP restriction that also handles anything sensitive — real customer-shaped test data, admin functionality — should still have its own authentication layer. IP restriction narrows the population that can even attempt to authenticate; it is a network-layer control, layered on top of (not instead of) an application-layer one.
This distinction matters concretely for shared-VPN scenarios: if your entire company routes through one VPN egress IP, allowlisting that IP means "anyone at the company can reach this," not "only the three people who should see this can reach it." For environments with a narrower legitimate audience than "the whole company," IP restriction alone is the wrong tool — pair it with actual authentication, or narrow the CIDR range to something closer to the actual set of people who need access.
A Common Mistake: Restricting the Wrong Layer
Teams new to this configuration sometimes attempt to solve access restriction at the application layer instead — adding a basic-auth prompt or a hardcoded password check inside the app itself, guarding the staging environment with a login screen. This works, but it's solving the problem at a less reliable layer than the ingress-level control described in this guide: an application-layer check depends on every route in the app correctly enforcing it (a new endpoint added without the check is exposed by omission), whereas an ingress-level IP restriction applies uniformly to every request reaching any host in the environment, with no per-route implementation to get wrong. Where both are relevant — a genuinely sensitive environment might reasonably want both a network-level restriction and an application-level login — the ingress-level control should be the first line of defense, not a substitute reached for only after realizing the application-layer check has a gap.
Combining This With Network Isolation
IP restriction controls who from outside the cluster can reach a given environment's public hostname. It is a separate control from namespace-level network isolation between environments running on the same cluster — that isolation (covered in Custom Domains and Network Isolation) contains lateral movement between environments regardless of who could reach any one of them from outside. Both matter; neither substitutes for the other.
IPv6 Considerations
whitelist-source-range accepts both IPv4 and IPv6 CIDR notation, but it's easy to configure an allowlist that only accounts for IPv4 and then get unexpectedly blocked (or, worse, unexpectedly not blocked) traffic from a client connecting over IPv6 — increasingly common as ISPs and corporate networks roll out dual-stack connectivity. If your ingress controller and cluster networking support IPv6 traffic at all, an allowlist listing only IPv4 ranges may either reject legitimate IPv6-originating traffic outright, or — depending on how your specific ingress controller handles an unmatched address family — fail open in a way that's worth explicitly testing rather than assuming. The safe practice is including both the IPv4 and IPv6 ranges for every legitimate source (most corporate VPNs and CI providers publish both) rather than assuming IPv4 coverage is sufficient.
Testing Your Allowlist Actually Works
Before relying on an IP restriction for anything sensitive, verify it from both sides — confirm a request from an allowed IP succeeds, and confirm a request from a disallowed one is actually rejected, rather than assuming the annotation took effect correctly:
1# From an allowed network
2curl -s -o /dev/null -w "%{http_code}\n" https://staging-example.bunnyenv.com/
3
4# From a disallowed network (a mobile hotspot, a different office, a cloud shell
5# outside your allowlisted ranges works well for this test)
6curl -s -o /dev/null -w "%{http_code}\n" https://staging-example.bunnyenv.com/A correctly configured restriction returns a normal response code from the allowed source and a 403 (or connection failure, depending on ingress controller configuration) from the disallowed one. Testing this once at setup time, and again after any change to the allowlist, catches the class of mistake where an annotation is present but syntactically wrong (a malformed CIDR, a typo in an IP) and therefore silently ignored rather than enforced — which looks identical to "working" until someone actually tries to use it from an unauthorized location.
Combining IP Restriction With VPN and Zero Trust Tooling
For teams already using a Zero Trust network access tool (Cloudflare Access, Tailscale, a corporate VPN with a known egress range), IP restriction on a Bunnyshell environment composes naturally with that existing investment rather than requiring a separate access system. The practical pattern: route all legitimate staging-environment traffic (reviewers, QA, CI) through the Zero Trust tool's known egress IP or range, and allowlist only that range at the ingress level. This gets you the benefit of the Zero Trust tool's own authentication and device-posture checks (who is this person, is their device compliant) layered underneath a simple, coarse network-level restriction, without needing to reimplement identity-aware access control at the Kubernetes ingress layer directly.
Documenting the Allowlist as a Living Artifact
An IP allowlist that exists only as a string of CIDR ranges embedded in a YAML file, with no accompanying record of why each range is there, degrades in usefulness over time — six months later, nobody remembers whether 198.51.100.14/32 is a critical piece of infrastructure or a one-off IP someone added temporarily and forgot to remove. Treating the allowlist as a small, explicitly documented artifact (a comment block above the annotation, or a companion README section) listing each range and its owning purpose — "203.0.113.0/24: company VPN egress, owned by IT," "198.51.100.14/32: CI runner static IP, see infra repo" — turns a periodic security review from an archaeology exercise into a five-minute read. This is a small amount of upfront documentation discipline that pays for itself the first time someone needs to determine, under time pressure, whether a specific range is safe to remove.
1# IP allowlist for staging-example:
2# 203.0.113.0/24 - Company VPN egress (owner: IT, added 2026-01)
3# 198.51.100.14/32 - CI static egress IP (owner: platform team, added 2026-03)
4nginx.ingress.kubernetes.io/whitelist-source-range: '203.0.113.0/24,198.51.100.14/32'Reviewing the Allowlist on a Schedule, Not Just at Setup
An allowlist configured correctly once and never revisited tends to accumulate the same kind of drift as any access-control list left unattended — a departed contractor's IP still listed, a CI provider's published range that changed months ago and was never updated, a temporary addition for a one-time incident that was never removed afterward. Pairing the documentation practice described above with a genuinely scheduled review (quarterly is a reasonable cadence for most teams) — not "we'll get to it eventually" — is what keeps an allowlist an accurate reflection of who should currently have access, rather than a historical record of who once did.
Applying This Consistently Across Ephemeral Environments
For a primary environment configured to spawn ephemeral copies automatically, IP restriction annotations included in the primary's bunnyshell.yaml propagate to every ephemeral environment derived from it, the same as any other structural configuration (see the structural-vs-per-instance distinction in One Staging Blueprint, Infinite Ephemeral Copies). This is the practical reason to configure IP restriction at the primary level rather than adding it manually to individual ephemeral environments after the fact — a restriction added only to one PR's environment, forgotten on the next, produces exactly the inconsistent security posture that undermines confidence in the control altogether. If every ephemeral environment for a given application should carry the same restriction, that decision belongs in the one place — the primary's tracked configuration — that every derived environment inherits from automatically.
A Note on Staging vs. Production Restriction Policies
It's worth resisting the temptation to copy a production environment's access policy onto staging verbatim, in either direction. A staging environment usually needs a broader allowlist than production, since reviewers, QA, and CI all need reach that a production environment's much narrower operational-access policy wouldn't grant. Conversely, staging should never be treated as needing less rigor just because it isn't "the real thing" — the data and credentials it handles, as covered throughout this pillar, are frequently real enough to warrant the same seriousness, applied through a policy shaped for staging's actual, different set of legitimate users.
Related Reading
For the certificate and DNS side of exposing a staging environment safely, see Custom Domains and Network Isolation. For the secrets-handling half of a complete security posture, see Securing E2E Test Environments — IP restriction, custom domains, and secret encryption together are the practical security surface most staging and PR-preview environments actually need to get right.
Checklist
- Any environment holding sensitive or realistic customer-shaped data has IP restriction configured, not left at the public default
- The allowlist is built from durable sources (CI provider IP ranges, VPN egress, fixed proxy) rather than one-off IPs that will silently expire
- Environment-wide restriction with explicit per-host public opt-in is used for multi-host environments, rather than remembering to restrict each host individually
- IP restriction is understood as a network-layer control, not a substitute for application-level authentication where that's also warranted
Lock down who can reach your staging environments.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.
Frequently Asked Questions
Is a staging environment publicly reachable by default?
Yes — Bunnyshell environments get a real, publicly resolvable HTTPS URL by default, since that is what makes them useful for CI, reviewers, and stakeholders without VPN setup. Whether that public reachability should be narrowed to specific IPs is a decision you make explicitly, not something enforced by default.
What is the exact mechanism used for IP restriction?
The standard NGINX ingress controller annotation nginx.ingress.kubernetes.io/whitelist-source-range, which accepts a comma-separated list of CIDR ranges. Requests from outside the listed ranges are rejected at the ingress layer before reaching your application.
Can I restrict the whole environment at once instead of per host?
Yes, an environment-wide restriction mode exists for cases where you want a closed-by-default posture with only specific hosts opted into being public, which is generally the safer default for multi-host environments.
Will IP restriction break CI jobs that run E2E tests against the environment?
Only if the CI runner's IP isn't included in the allowlist. The standard fix is allowlisting your CI provider's published IP ranges, or routing CI traffic through a fixed egress IP, rather than allowlisting a per-run IP that changes.
Does IP restriction replace the need for authentication?
No — IP restriction controls network-level reachability, not identity. An environment that also contains sensitive data or actions should still have its own authentication layer; IP restriction narrows who can even attempt to reach the login page, it does not replace the login page.