The no-egress checklist: every outbound call an LLM stack makes, and how to kill each one
An enumeration of every category of outbound network call an LLM application stack makes — DNS, NTP, certificate validation, telemetry, model hubs, package managers, fonts, update checkers, crash reporters — with the specific control that stops each, and a method for proving it.
Why this piece exists
A complete category-by-category inventory of outbound calls in a self-hosted LLM stack, including the four almost nobody lists — OCSP and CRL fetches during TLS validation, ACME certificate renewal, snap and package auto-refresh, and the application's own user-controlled URL fetches — each paired with the control that actually stops it and a network-namespace method for proving the stack still works without any of them.
An LLM stack makes far more outbound calls than the model does. The model is often the only component that has been checked. Everything around it — the runtime, the package manager that installed it, the certificate library that validates its TLS, the dashboard that monitors it, the font the browser loads to render its output — has its own opinions about reaching the internet, and most of those opinions are defaults nobody chose.
This is the inventory. Each category names what makes the call, what it looks like on the wire, and the control that stops it. The order is roughly the order in which each one will surprise you.
The categories, in one table
Eleven categories of outbound call exist in a typical self-hosted LLM deployment. Nine of them can be eliminated outright; two need a replacement rather than a block.
| # | Category | Typical destination | Control | Eliminate or replace |
|---|---|---|---|---|
| 1 | DNS resolution | Public resolvers, upstream forwarders | Authoritative internal resolver, no forwarders | Replace |
| 2 | Time synchronisation | *.pool.ntp.org, vendor NTP | Internal stratum-1 or stratum-2 source | Replace |
| 3 | TLS revocation checking | OCSP responders, CRL distribution points | Internal CA, revocation checking configured explicitly | Replace |
| 4 | Certificate issuance and renewal | ACME directory endpoints | Internal CA with a long-lived issuance path | Replace |
| 5 | Product telemetry and analytics | Vendor stats endpoints, PostHog, Segment | Opt-out settings plus a network block | Eliminate |
| 6 | Version and update checks | Vendor release APIs, plugin catalogues | Configuration flags plus a network block | Eliminate |
| 7 | Crash and error reporting | Sentry DSNs, vendor crash endpoints | Unset the DSN; self-host the collector | Eliminate or replace |
| 8 | Model hub pulls | Hugging Face, Ollama registry, S3 mirrors | Offline env vars plus a local registry | Eliminate |
| 9 | Package and image pulls | PyPI, npm, container registries, distro mirrors | Internal mirror; nothing pulled at runtime | Replace |
| 10 | Frontend asset fetches | Font and script CDNs, map tiles, favicons | Self-host every asset; enforce with CSP | Eliminate |
| 11 | The application's own fetches | Anywhere a user or a model can name | A single egress chokepoint with an allowlist | Eliminate |
DNS is the first control and the one that leaks
DNS is the first thing to fix in a no-egress deployment, because a resolver with an upstream forwarder turns every hostname any component ever mentions into an outbound query — including hostnames that only appear in error paths. A blocked HTTPS connection still emits the DNS lookup that preceded it, and that lookup carries the name.
Run an internal resolver that is authoritative for your zones and returns NXDOMAIN for everything else, with no forwarders configured. Then verify it from the inside, because a component may ship its own resolver library or its own hardcoded DNS server:
# Should return the internal address. If anything answers for a public name,
# something is forwarding.
dig +short registry.ollama.ai
dig +short stats.grafana.org
dig +short huggingface.co
The failure mode worth naming: some Java runtimes cache successful DNS results indefinitely (networkaddress.cache.ttl), and some Go binaries bypass the system resolver entirely depending on how they were built. Test each component, not the host.
Time synchronisation has to be replaced, not blocked
NTP is the one category where blocking creates a worse problem than the traffic did. Certificate validation, token expiry, log correlation and any signature-verification step all depend on the clock, and an isolated environment drifts. systemd-timesyncd and chrony ship with vendor NTP pools configured by default, so the work is to repoint them rather than to remove them.
# /etc/chrony/chrony.conf — internal source only, no pool directive.
server time.internal.example iburst
makestep 1.0 3
rtcsync
If there is no internal time source at all, that is a gap to fix before the LLM stack is deployed, not after. A GPS-disciplined stratum-1 appliance is inexpensive relative to the cost of debugging expired-token errors on a cluster whose clocks have drifted apart.
Certificate validation phones home, and almost nobody lists it
TLS validation is an outbound-call category in its own right, and it is the one missing from every no-egress checklist we have read. Two mechanisms are involved. OCSP makes a live HTTP request to the issuing CA’s responder to ask whether a certificate is revoked. CRL distribution points are HTTP URLs embedded in the certificate itself, fetched to download a revocation list.
Both are triggered by validating a certificate issued by a public CA. If any internal service presents a publicly-issued certificate, every client that validates it may attempt a fetch to a public responder — and in a blocked environment the visible symptom is a multi-second connection stall, not an error, because most stacks soft-fail.
The fix is structural: issue every internal certificate from an internal CA, publish the CRL at an internal URL, and configure clients to check it there. Do not simply block the responders and leave publicly-issued certificates in place; you will get the stalls without the security property.
The same reasoning applies to ACME. An internal service holding a certificate from a public ACME provider needs outbound access every sixty days or it stops working, and the renewal failure is asynchronous and quiet. Internal CAs with multi-year issuance are the correct answer for an isolated environment, and the operational cost is distributing one root certificate.
Telemetry opt-outs are documentation, not a control
Every major component in an LLM stack collects usage statistics by default, and each has its own opt-out. Set all of them — and then treat the network block as the real control, because a flag can regress in a minor release and no one will notice.
| Component | Opt-out | Notes |
|---|---|---|
| vLLM | VLLM_NO_USAGE_STATS=1, VLLM_DO_NOT_TRACK=1, DO_NOT_TRACK=1, or $HOME/.config/vllm/do_not_track | Collected data is previewable at ~/.config/vllm/usage_stats.json |
| Hugging Face libraries | HF_HUB_DISABLE_TELEMETRY=1 | Also disabled implicitly by HF_HUB_OFFLINE=1 |
| Grafana | [analytics] reporting_enabled = false, check_for_updates = false, check_for_plugin_updates = false | Bugs have been reported where these were not fully honoured in specific releases |
| Broad convention | DO_NOT_TRACK=1 | Honoured by a growing set of CLI tools per the console do-not-track convention |
Two of those rows carry the lesson. vLLM’s usage statistics documentation is unusually good — it tells you exactly what is collected and gives you three ways to stop it. Grafana’s flags are equally well documented, and there are public reports of specific releases where a plugin subsystem reached out to grafana.com anyway despite check_for_plugin_updates = false. Neither vendor is behaving badly. Opt-out paths are simply not the code path anyone regression-tests.
Model hubs and inference runtimes
Model-hub traffic is easy to eliminate and easy to leave half-eliminated, because the pull only happens on a cache miss. A service that has been running for a month with a warm cache looks perfectly offline right up until someone changes a model identifier by one character.
# Set at the unit level, not in a shell profile.
Environment=HF_HUB_OFFLINE=1
Environment=TRANSFORMERS_OFFLINE=1
Environment=HF_HOME=/srv/models/hf
HF_HUB_OFFLINE=1 prevents HTTP calls to the Hub when loading a model, per the huggingface_hub environment variable reference. For Ollama, models resolve against registry.ollama.ai by default; build models locally from a carried GGUF with a Modelfile and tag them under an internal namespace so nothing ever resolves upstream. Verify the outbound behaviour of the specific version you deploy with a packet capture rather than from a blog post — the environment variables that supposedly control it vary between releases, and several widely-repeated ones do not exist.
Package managers belong to build time, not run time
The strongest control here is not a mirror. It is a rule: nothing installs at run time. A container that runs pip install on start-up, an entrypoint that calls npm ci, a Helm chart that pulls a plugin — each turns a running production service into a build, and a build needs egress by definition.
Bake dependencies into images at build time on the connected side, mirror your registries internally, and set the runtime configuration so that a cache miss fails loudly:
# /etc/pip.conf
[global]
index-url = https://pypi.internal.example/simple
trusted-host = pypi.internal.example
no-cache-dir = false
The distro-level equivalent is worth checking specifically. Ubuntu hosts run unattended-upgrades and snapd; snapd in particular refreshes on its own schedule and resists being permanently disabled. Either remove it from the image or accept a permanent, visible block in your egress logs.
Frontend assets are the most common single leak
The most frequent egress leak in an otherwise careful deployment is a web font. A single <link> to a font CDN means every browser session in the organisation makes a request to a third party, carrying the referrer, on a page that is otherwise entirely internal. It shows up in no server-side audit because the request is made by the browser, not by your server.
Digisky self-hosts every font specifically for this reason. It is a decision about dependency, not about performance: an application that cannot render its own interface without a third party is not a self-contained application. The same applies to icon sets, map tiles, analytics scripts and remotely-hosted favicons.
Make the browser enforce it, so the next developer cannot reintroduce it:
Content-Security-Policy:
default-src 'self';
font-src 'self';
img-src 'self' data:;
connect-src 'self';
script-src 'self';
frame-ancestors 'none';
base-uri 'self'
A CSP with default-src 'self' converts an accidental CDN reference from a silent leak into a console error during development. That is the whole value: the failure becomes visible to the person who caused it.
The application’s own outbound calls need one chokepoint
The last category is the hardest, because the calls are legitimate. An LLM application fetches things: a URL a user pasted, a webhook a customer configured, a document a tool call named. Each of those is an outbound request whose destination is chosen by someone other than you — which is the definition of server-side request forgery.
The control is a single chokepoint that every outbound HTTP call in the codebase goes through. DataCopilot has one, and its existence is the point: there is exactly one function that can make an outbound request, so there is exactly one place to enforce policy and one place to audit.
# egress.py — the only module permitted to open an outbound connection.
# requests 2.32, urllib3 2.x. The DNS pin is the part that matters.
import ipaddress, socket
from urllib.parse import urlparse
import requests
from urllib3.util import connection
ALLOWED_HOSTS = {"reports.internal.example", "erp.internal.example"}
ALLOWED_SCHEMES = {"https"}
class EgressDenied(Exception):
pass
def _resolve_once(host: str) -> str:
"""Resolve, validate, and return the single address we will connect to."""
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
addrs = {i[4][0] for i in infos}
if not addrs:
raise EgressDenied(f"no address for {host}")
for a in addrs:
ip = ipaddress.ip_address(a)
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast):
raise EgressDenied(f"{host} resolves to non-routable {a}")
return sorted(addrs)[0]
def fetch(url: str, *, timeout: float = 10.0) -> requests.Response:
parts = urlparse(url)
if parts.scheme not in ALLOWED_SCHEMES:
raise EgressDenied(f"scheme {parts.scheme!r} not permitted")
if parts.hostname not in ALLOWED_HOSTS:
raise EgressDenied(f"host {parts.hostname!r} not on the allowlist")
pinned = _resolve_once(parts.hostname)
# Connect to the address we validated, not to whatever DNS answers next.
# Without this, an attacker-controlled record can pass validation and then
# resolve to 169.254.169.254 on the connection attempt.
original = connection.create_connection
def pinned_connection(address, *args, **kwargs):
_host, port = address
return original((pinned, port), *args, **kwargs)
connection.create_connection = pinned_connection
try:
return requests.get(url, timeout=timeout, allow_redirects=False)
finally:
connection.create_connection = original
Three properties of that function are the ones that matter, and each corresponds to a real bypass. allow_redirects=False stops an allowlisted host from redirecting to a denied one. The address pin closes the DNS-rebinding window between validation and connection. And the allowlist is a set of hostnames, not a regular expression, because every SSRF filter written as a regular expression has eventually been defeated by a URL that parses differently in two libraries.
Prove it: deny by default, then run the stack with no route
Two mechanisms turn this from a checklist into a property. The first is a deny-by-default egress policy on the host, so that anything not listed above fails visibly rather than succeeding quietly:
# /etc/nftables.conf
table inet filter {
chain output {
type filter hook output priority 0; policy drop;
ct state established,related accept
oif "lo" accept
ip daddr 10.0.0.0/8 accept comment "internal networks"
ip daddr 172.16.0.0/12 accept
ip daddr 192.168.0.0/16 accept
udp dport 53 ip daddr 10.10.0.53 accept comment "internal resolver"
udp dport 123 ip daddr 10.10.0.10 accept comment "internal NTP"
log prefix "EGRESS-DROP " limit rate 10/minute
}
}
The log rule at the end is the useful half. Run it for a week in a staging environment and read the drops: that log is the real inventory of what your stack tries to reach, and it will contain components you did not know were installed.
The second mechanism is a test. Run the whole stack in a network namespace with no default route and assert that it still serves a request:
# no-egress-test.sh — the stack must work with no route off the host.
set -euo pipefail
sudo ip netns add offline
sudo ip netns exec offline ip link set lo up
# Only loopback exists in this namespace. No default route, no gateway.
sudo ip netns exec offline env \
HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 DO_NOT_TRACK=1 \
./run-stack.sh &
sleep 30
sudo ip netns exec offline curl -fsS \
-X POST http://127.0.0.1:8000/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"internal/analyst","messages":[{"role":"user","content":"ping"}]}'
sudo ip netns delete offline
Put that in continuous integration. A no-egress deployment that is verified only by reading configuration files is a deployment that will acquire an outbound dependency in the next dependency bump, and nobody will find out until the network team asks why a production host is querying a public resolver.
Where this stops applying
This checklist targets a self-contained deployment whose only external dependency is intentional. It does not apply cleanly in four cases.
If you use a hosted model API, egress is the product. The work then is not elimination but constraint: one destination, one chokepoint, TLS pinning, and a documented data-flow record — a different exercise from this one.
If you operate a regulated environment with a mandated outbound proxy, replace “block” with “route through the proxy and log”, and keep the chokepoint and the CSP. The DNS, NTP and certificate sections still apply unchanged.
If you are running on managed Kubernetes, several of these controls belong at a different layer — NetworkPolicy, egress gateways and node-level DNS configuration — and the host-level nftables example above will be overwritten by the platform.
And if your organisation depends on public certificate transparency or public revocation infrastructure for a compliance reason, the certificate section is advice you cannot take. In that case, document the exception explicitly rather than leaving it as an unexplained hole in an otherwise closed policy. An accurate map of one intentional gap is worth more than a diagram claiming there are none.