How-to · Zero Trust edge

Lock pre-prod hosts to your VPN with one field

Keep int. and test. hostnames on public DNS, but answer only to clients that come in through your VPN or office network. Everyone else gets a branded 403 "VPN required" page. You don't need to reload nginx or set up private DNS.

Access profiles · cidr matching · fail-closed by design

Request flow from a VPN client through DNS and the WSLProxy edge, showing the per-request access-profile check
Where the access-profile check sits in the WSLProxy request path.

Why not just allow/deny in nginx?

Static allow/deny blocks need a reload for every change, and they live outside the control plane that already routes the host. Hand-written WSLProxy rules can do it, but a VPN rule only works in a pair. The selector drops any rule whose IP check fails, so a lone "allow from VPN" rule lets off-VPN traffic fall through to the catch-all, and that traffic gets proxied anyway. One forgotten deny rule leaves a hole that nobody notices.

An access profile is a named bundle of protected endpoints. Each endpoint expands into the correct allow/deny pair when the config loads, so the pairing is never left to memory.

1. Get the real client IP right first

cidr matching checks remote_addr. If Cloudflare or another proxy sits in front, configure data/real_ip/ before anything else. Then check:

curl -sI https://int.example.com/ | grep -i x-origin-ip

It must show your public or VPN exit address, not the front proxy's.

2. Define the profile

Create data/access_profiles/prod/example-preprod.json on every edge that serves the host:

{
  "name": "example-preprod",
  "allow_cidrs": "203.0.113.10/32, 10.8.1.0/24",
  "origin": "http://10.0.0.20:8080",
  "endpoints": [
    { "path": "/", "path_key": "starts_with" }
  ]
}

3. Attach it to the host

Add one field to data/servers/prod/host:int.example.com.json:

"access_profile": "example-preprod"

The rules take effect on the next request, without a reload. Generated rules start at priority 1000, so they outrank the host's own catch-all. Within a profile, a narrower path outranks a broader one, so you can protect /admin/billing more tightly than /admin.

Install order matters. Put the profile file on every edge before any host references it. If a profile is missing or invalid, the host fails closed: every request gets the 403 page, and the error log says access_profile failed, denying all requests for <host>.

4. Verify from both sides

# Off the VPN — expect 403 and the "VPN required" page
curl -so /dev/null -w '%{http_code}\n' https://int.example.com/

# Either side — see the verdict
curl -sI https://int.example.com/ | grep -iE '^x-(wsl-vpn|origin-ip)'
# X-WSL-VPN: blocked
# X-WSL-VPN-Profile: example-preprod
# X-WSL-VPN-Reason: client-ip-not-allowed

Check both sides. If the allow rule works but the deny rule is broken, it looks exactly like a working setup from inside the VPN. ACME challenges stay reachable without the VPN, because /.well-known/acme-challenge/ has its own nginx location that never runs the rule gateway. Certificates keep renewing.

The "VPN required" page

Denied visitors see a self-contained page with light and dark styling, a reload button, and a link back to wslproxy.org, so anyone who lands on it can see what is protecting the host. Set deny_message on a profile to replace it with your own HTML.

Going further: per-user access

A CIDR check answers "did this request come in over the VPN?". To answer "who sent it?", set WSL_CONTROL_URL, then add groups to a profile or endpoint. WSLProxy resolves the client address against the wslvpn control plane and matches group membership. That is the Zero Trust step up from a network allow-list.

Read more