Walk one request from a phone on a train to a backend that only exists inside a private network. The phone resolves a public name, connects to a public address on port 443, and completes a normal TLS handshake. Nginx accepts that connection and then opens a second, entirely separate one to an address it can only reach through a VPN interface. Nothing about the phone changes, and the phone never learns that a tunnel was involved.

Follow one request through two connections

The inbound leg is a public HTTP transaction. It has a public name, a public certificate, and a reply path that has to work for whatever address the visitor happens to hold. Any change that disturbs that path breaks every visitor at once. That is why the public leg is the one you least want to experiment on.

The outbound leg is a private one. Nginx becomes a client, resolves an internal name or uses a fixed address, and reaches a service that would refuse a connection from the internet.

Public client  -> frontend public address:443   (TLS, public name)
Frontend       -> VPN private backend 10.40.0.20:8443  (TLS, private name)

Keep them separate in your head and the configuration becomes obvious. Merge them, and you end up trying to make a public client participate in a tunnel, which is where most of the confusion in this design comes from. The private leg is allowed to be fragile. The public leg is not.

Before building any of it, confirm that publishing the application is intended. A working proxy is also a working exposure, and the authentication model has to be a decision rather than a side effect.

The gateway name has to survive the tunnel

A subtle trap sits in name resolution. If private DNS overrides the same name the VPN profile uses for its own gateway, a reconnect can end up trying to reach that gateway through the tunnel it is supposed to establish. The client needs that name to resolve before the tunnel exists. Once it resolves inside the tunnel, the connection can never be built in the first place.

Use distinct approved names for the gateway and the backend, and scope resolver policy so each name resolves to the address its consumer needs. Changing an application's Host header does nothing for this problem, because the VPN client is the one doing the lookup.

Then think about scope. The frontend needs a route to the backend address, and it also needs to keep answering public clients. A full tunnel on that host can satisfy the first requirement and break the second.

Where the policy allows it, a specific-destination tunnel is the cleaner design. Where the policy mandates a full tunnel, the frontend belongs in a separate network context rather than being patched with ad hoc routes for known visitors.

Write the server block so both legs verify

Assume the backend presents a certificate for backend.corp.example and answers on a private address. Connecting to an address while verifying a name is exactly what the proxy module's SNI and name options are for.

upstream private_app {
    server 10.40.0.20:8443;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/tls/app-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/app-key.pem;

    location / {
        proxy_pass https://private_app;
        proxy_ssl_server_name on;
        proxy_ssl_name backend.corp.example;
        proxy_ssl_trusted_certificate /etc/nginx/trust/backend-ca.pem;
        proxy_ssl_verify on;
        proxy_ssl_verify_depth 3;

        proxy_set_header Host app.example.com;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

Every path and address above is a placeholder. The important parts are the ones that keep verification switched on: a name to check, a trust bundle to check it against, and a depth that matches the approved chain.

proxy_set_header Host app.example.com is deliberate. The backend should be configured to accept that public origin, and it should trust forwarded headers only from this frontend. Overwriting X-Forwarded-For with the client address is right for a single proxy and wrong for a chain, so a multi-proxy deployment needs its own trusted-address arrangement.

Make the public origin behave like one

The application should emit absolute URLs, redirects and cookies for the public origin. If it redirects a visitor to backend.corp.example, that visitor is sent to a name they cannot resolve.

Fix that through the application's supported external-URL setting and a reviewed redirect policy, not by rewriting hostnames in response bodies. String replacement across arbitrary content breaks signed payloads and creates bugs that are hard to trace.

Cookies deserve the same care. A cookie scoped to the private name will never be sent by a browser that only knows the public one.

Also confirm that Nginx can actually own the public socket. An existing web service on 443 has to move or the frontend has to live elsewhere, because two processes cannot bind the same address and port.

Check each leg where it actually runs

Validate the configuration with the installed parser before any reload, so a typo does not take down the listener that is currently serving.

Then test the backend leg from the frontend host itself, in the same network context Nginx uses. A ping across the tunnel proves reachability and nothing about TLS, authorization or the application.

Finally make a fresh public request to the real hostname with normal certificate verification, and observe the reply path rather than only the response body.

Keep both observations with the deployed configuration, and record what happens when the tunnel is unavailable. The frontend should return a controlled upstream error, not quietly fall back to a different backend that nobody documented. A 502 with a clear log line is a good outcome. A cached page from an unintended origin is not.

Erthyglow kevrennek