Migrate from Flat VPN to Per-App Access Without Breaking Internal Apps
For developers replacing broad network-level VPN access with application-level access incrementally. This guide shows how to front one internal HTTP app at a time with an identity-aware reverse proxy, keep the old VPN path during rollout, and verify behavior with curl, headers, and logs.
TL;DR — Replace VPN-wide network access with an identity-gated reverse proxy per application, starting with a single hostname like
app1.internal.example.com. The fastest safe path is: publish a new DNS name, put nginx in front of the app, require OIDC login there, restrict backend reachability to the proxy host, and leave the old VPN path alive untilcurland browser tests pass. Reading time: ~5 min
Goal
When you finish, one internal web application is reachable through its own hostname over HTTPS and requires your IdP login before the app responds, while direct network access to that app is limited to the reverse proxy host; the rest of your VPN-based environment remains unchanged until you migrate the next app.
Prerequisites
- A Linux host to run the reverse proxy, with public DNS pointing to it, and shell access with
sudo - nginx
>= 1.22— check withnginx -v - A valid TLS certificate for the app hostname, or ACME tooling such as
certbot >= 2— check withcertbot --version - An OpenID Connect-capable IdP app registration with these values at hand:
client_id,client_secret,issuer,redirect_uri - One internal HTTP app already running, with its current private address and port, for example
10.20.30.40:8080 - DNS control for a new hostname, for example
app1.internal.example.com - Firewall control on the app host or security group controlling who can reach the app port
curl >= 8andjq >= 1.6on your workstation — check withcurl --versionandjq --version
Steps
Step 1: Create a new per-app hostname
Add a DNS record in your provider's dashboard (for example, Cloudflare: DNS → Records; Route 53: Hosted zones → your zone → Create record) with these literal values:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | app1.internal.example.com | 203.0.113.10 | 300 |
If you already use IPv6, add this too:
| Type | Name | Value | TTL |
|---|---|---|---|
| AAAA | app1.internal.example.com | 2001:db8::10 | 300 |
Success looks like this:
$ dig +short app1.internal.example.com A
203.0.113.10
Step 2: Obtain or install the TLS certificate
If you use Let's Encrypt and the proxy host is internet-reachable on port 80, run:
sudo certbot certonly --nginx -d app1.internal.example.com --non-interactive --agree-tos -m ops@example.com
If you already have certificate files, place them here:
sudo install -d -m 0755 /etc/nginx/tls
sudo install -m 0644 app1.internal.example.com.crt /etc/nginx/tls/
sudo install -m 0600 app1.internal.example.com.key /etc/nginx/tls/
Success looks like this:
$ sudo openssl x509 -in /etc/letsencrypt/live/app1.internal.example.com/fullchain.pem -noout -subject -issuer
subject=CN = app1.internal.example.com
issuer=C = US, O = Let's Encrypt, CN = E8
Step 3: Add OIDC gatekeeping in front of the app
This example uses oauth2-proxy in front of nginx because it is common and vendor-neutral.
Create the oauth2-proxy config:
sudo tee /etc/oauth2-proxy.cfg >/dev/null <<'EOF'
provider = "oidc"
oidc_issuer_url = "https://idp.example.com/realms/engineering"
client_id = "app1-proxy"
client_secret = "REPLACE_WITH_CLIENT_SECRET"
redirect_url = "https://app1.internal.example.com/oauth2/callback"
email_domains = [ "*" ]
http_address = "127.0.0.1:4180"
upstreams = [ "http://10.20.30.40:8080/" ]
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax"
reverse_proxy = true
set_xauthrequest = true
pass_access_token = true
pass_authorization_header = true
whitelist_domains = ".internal.example.com"
EOF
Create the systemd unit:
sudo tee /etc/systemd/system/oauth2-proxy.service >/dev/null <<'EOF'
[Unit]
Description=oauth2-proxy
After=network.target
[Service]
ExecStart=/usr/local/bin/oauth2-proxy --config /etc/oauth2-proxy.cfg
Restart=always
RestartSec=3
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
sudo systemctl status --no-pager oauth2-proxy
Success looks like this:
● oauth2-proxy.service - oauth2-proxy
Loaded: loaded (/etc/systemd/system/oauth2-proxy.service; enabled)
Active: active (running)
Step 4: Configure nginx to require login before proxying
Create the nginx site:
server {
listen 80;
server_name app1.internal.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app1.internal.example.com;
ssl_certificate /etc/letsencrypt/live/app1.internal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app1.internal.example.com/privkey.pem;
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
}
location = /oauth2/auth {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
location / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-Forwarded-User $user;
proxy_set_header X-Forwarded-Email $email;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://10.20.30.40:8080;
}
}
Enable it and reload nginx:
sudo tee /etc/nginx/sites-available/app1.internal.example.com >/dev/null < app1.internal.example.com.nginx
sudo ln -s /etc/nginx/sites-available/app1.internal.example.com /etc/nginx/sites-enabled/app1.internal.example.com
sudo nginx -t && sudo systemctl reload nginx
Success looks like this:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 5: Restrict backend reachability to the proxy host
⚠️ This can cause downtime for this app if you block the wrong source IPs. Keep your existing VPN path in place until Step 6 passes, and apply the rule to the app port only.
On the app host, allow only the proxy host 203.0.113.10 to reach port 8080 and drop everyone else:
sudo iptables -I INPUT -p tcp -s 203.0.113.10 --dport 8080 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP
sudo iptables -S INPUT | grep 8080
If you use ufw instead:
sudo ufw allow from 203.0.113.10 to any port 8080 proto tcp
sudo ufw deny 8080/tcp
sudo ufw status numbered
Success looks like this:
-A INPUT -s 203.0.113.10/32 -p tcp -m tcp --dport 8080 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 8080 -j DROP
Step 6: Cut users over to the new hostname and leave VPN as fallback
Send users to https://app1.internal.example.com instead of the old private URL. Do not remove the old VPN route yet. If the app generates absolute URLs, set its external base URL to the new hostname. Common examples:
export BASE_URL="https://app1.internal.example.com"
export PUBLIC_URL="https://app1.internal.example.com"
Success looks like this: after login, links and redirects stay on https://app1.internal.example.com/... instead of bouncing to http://10.20.30.40:8080/....
Verify it works
Check the redirect to HTTPS:
curl -I http://app1.internal.example.com
Expected shape:
HTTP/1.1 301 Moved Permanently
Location: https://app1.internal.example.com/
Check that unauthenticated access is intercepted by the auth layer:
curl -k -I https://app1.internal.example.com/
Expected shape:
HTTP/2 302
location: /oauth2/sign_in
Check the login flow in a browser. The sequence should look like this:
View as text
[Browser] ---- 1. GET / ----> [nginx] [nginx] ---- 2. auth_request ----> [oauth2-proxy] [oauth2-proxy] ---- 3. redirect ----> [IdP] [IdP] ---- 4. callback code ----> [oauth2-proxy] [oauth2-proxy] ---- 5. session cookie ----> [Browser] [Browser] ---- 6. GET / with cookie ----> [nginx] [nginx] ---- 7. proxy ----> [App]
Check that the backend is no longer directly reachable except from the proxy host. From a non-proxy machine on the same network:
curl -I --max-time 3 http://10.20.30.40:8080/
Expected result: timeout or connection refused, not HTTP/1.1 200 OK.
Check logs during a real request:
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
sudo journalctl -u oauth2-proxy -f
Expected result: a 302 to /oauth2/sign_in, then a successful callback, then a 200 from the upstream app.
Common pitfalls
Wrong redirect URI in the IdP app registration
Mistake: registering https://app1.internal.example.com/ instead of https://app1.internal.example.com/oauth2/callback.
Symptom: browser lands on an IdP error page or oauth2-proxy logs redirect_uri_mismatch.
Fix: set the redirect URI to the exact callback path https://app1.internal.example.com/oauth2/callback.
App generates redirects to its private address
Mistake: leaving the app's public/base URL set to http://10.20.30.40:8080.
Symptom: curl -I https://app1.internal.example.com eventually shows Location: http://10.20.30.40:8080/login or the browser jumps off the public hostname.
Fix: set the app's external URL to https://app1.internal.example.com and keep proxy_set_header Host $host; in nginx.
TLS certificate does not match the new hostname
Mistake: reusing a cert for another name.
Symptom: curl -I https://app1.internal.example.com returns curl: (60) SSL: no alternative certificate subject name matches target host name.
Fix: issue a certificate with app1.internal.example.com in SAN/CN and reload nginx.
Firewall rule order blocks the proxy too
Mistake: adding the DROP rule before the ACCEPT rule.
Symptom: nginx returns 502 Bad Gateway, and error.log shows connect() failed (111: Connection refused) or timeout to 10.20.30.40:8080.
Fix: insert the allow rule first with iptables -I INPUT ... ACCEPT, then append the drop rule.
Cookie or callback breaks behind HTTPS offload
Mistake: cookie_secure = false or missing X-Forwarded-Proto https.
Symptom: login loop; browser returns to /oauth2/sign_in after successful IdP auth.
Fix: set cookie_secure = true in oauth2-proxy and proxy_set_header X-Forwarded-Proto https; in nginx.
Removing the old VPN path too early
Mistake: deleting routes or firewall exceptions before verifying app-specific behavior like websockets, large uploads, or callback URLs. Symptom: main page works, but file upload, SSE/websocket, or webhook callback fails only after cutover. Fix: keep the VPN path as fallback until end-to-end tests for that app pass, then remove only that app's VPN dependency.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI