Your laptop is on the VPN, dig service.internal.example answers with a 10.x address, and curl still exits with Could not resolve host. The tunnel is not the problem: it carried the dig query without complaint. What differs is who asks the question. dig sends one query to one server you name, while curl goes through the operating system's lookup chain, and that chain has its own rules about which server gets asked.

Two lookups for the same name

dig is a stub resolver of its own. It reads /etc/resolv.conf, picks a nameserver, sends a single A or AAAA query, and prints the reply. It skips nsswitch entirely and knows nothing about the per-link state that systemd-resolved or NetworkManager keeps outside that file, which is why a dig answer can be correct and still tell you nothing about what your application will see.

Applications call getaddrinfo, and getaddrinfo walks a longer chain: the nsswitch module order, then the module that handles the name, then the resolver built from the current link configuration. On a systemd machine the query goes to 127.0.0.53 and systemd-resolved picks an upstream per-link server based on the routing domain attached to the tun interface.

Two commands separate those layers without guessing:

getent ahosts service.internal.example
resolvectl query service.internal.example

getent uses the same path as curl, so a getent failure next to a working dig localizes the fault to lookup policy rather than packet routing. resolvectl query also prints the interface and upstream that supplied the answer, which shows whether the tunnel link or the physical link won the decision.

Read the resolver the application actually uses

/etc/resolv.conf is generated on most desktops, and a VPN client has three ways to interact with it: prepend a nameserver, overwrite the file, or hand its servers to NetworkManager and let the daemon rewrite it. Only the third pattern preserves per-link DNS, because the servers stay attached to an interface instead of being flattened into one global list.

On NetworkManager systems the outcome depends on the backend in /etc/NetworkManager/NetworkManager.conf:

[main]
dns=systemd-resolved

With dns=default the client's servers get merged into resolv.conf in whatever order wins, and a private lookup can still be sent to the public resolver. With a backend that understands links, systemd-resolved or dnsmasq, the tunnel's servers stay scoped to the tunnel and only names matching its routing domain go there. That scoping is what split DNS means in practice, and it is the piece most often missing when a client claims to support it.

Read the state before editing anything:

resolvectl status
resolvectl dns tun0

You want the tun interface listed with a DNS server and a routing domain for the private suffix, displayed as ~internal.example. No server on the link means the client pushed nothing. A server with no domain means lookups are either all sent there or none are, depending on the client, and neither is split DNS.

Where nsswitch ends the lookup early

A stock desktop line looks like this:

hosts: files mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns myhostname

mdns4_minimal handles multicast DNS, and it claims every name ending in .local. A Kubernetes service such as api.infra.svc.cluster.local ends in .local too. When the mDNS module reports NOTFOUND, the [NOTFOUND=return] action ends the whole lookup instead of continuing to the dns module, so the name is never sent to the VPN resolver. dig bypasses the file entirely, which is exactly why the two tools disagree.

That is the sharpest contrast in this problem, and it comes down to one word in a config file that predates your VPN. Changing the action to [NOTFOUND=continue] keeps mDNS for printers and local devices while letting DNS see the name. Replacing the line with hosts: files dns also works and is cruder: it drops .local resolution for the printer you actually use.

Names that live in exactly one network

Private zones are authoritative in one place. An AWS name such as ip-10-0-4-31.eu-west-1.compute.internal answers through that region's VPC resolver, and only while your VPN endpoint is in the same region as the resource. Connect to a Frankfurt endpoint, query an Ireland compute.internal name, and no local setting will help: the zone holding the record is not reachable from there.

Search domains produce the mirror image. A bare service gets completed with whichever suffix the resolver thinks applies, and with a VPN link the local domain can win over the corporate one. Query with a trailing dot to remove the guessing, getent ahosts service.internal.example., and if that works while the short name does not, the fix is the search domain on the tunnel, not the server address.

Managed clients add a third layer above the OS resolver. A Windows VPN profile can carry its own name-resolution policy, per-app rules, and a NRPT table that applies even when the tunnel is down. Applications with their own DNS cache, common in .NET and Java runtimes, keep a stale failure long after the chain is fixed. Apple clients configure split DNS through match domains on the DNS settings rather than through resolv.conf, and Chromium or Firefox in secure-DNS mode may bypass the system resolver for public names. Microsoft's VPN name-resolution reference describes the Windows side of that policy layer.

Decide which layer to fix

Work from the symptom to the layer instead of trying fixes in order:

SymptomWhat it meansCheck first
dig answers, getent failslookup chain, not the packet pathnsswitch line and resolvectl status
Timeout rather than NXDOMAINprivate resolver unreachable through the tunnelresolvectl dns tun0, route to that address
NXDOMAINan authority answered and does not know the namezone owner, endpoint region
FQDN works, short name failssearch suffix mismatchresolvectl domain on the tun link
getent works, app failsapp cache or its own resolverrestart the app, check secure DNS

Two rows carry most of the weight. A timeout is a reachability problem: the private resolver is not answering through the tunnel, so check the route to its address and whether the DNS server on the link is the one you think. NXDOMAIN is an authority problem: something replied, and it does not know the name, which points at the zone, the region, or a resolver that was never supposed to receive the query.

The last row is the one people miss because both halves look like success. A short name resolving differently from the FQDN is a suffix mismatch, and reconnecting the tunnel will not change which suffix the client pushes.

Capture the state before you edit a config file, so you can tell whether the edit did anything:

resolvectl status > before.txt
getent ahosts service.internal.example >> before.txt

Run the checks in this order and stop at the first one that fails. getent and resolvectl query must agree; if getent fails while dig answers, stay in the lookup chain and leave routing alone. resolvectl status must show a DNS server and a routing domain on the tun interface. nsswitch must not return early on NOTFOUND for the suffix you are testing. The name must be authoritative in the network your endpoint is attached to, tested with a trailing dot. The application must not hold its own cache or a secure-DNS override.

Fix the first failing layer only, then re-run the same commands. Two changes at once leave you unable to say which one mattered, and the next outage starts the investigation from zero.

Artitgels cun il medem tema