HTTP_PROXY and HTTPS_PROXY behave differently in every tool. Casing rules, precedence, the five ways NO_PROXY silently fails, and where they land.
· 17 min read · Configuration
Proxy environment variables HTTP_PROXY and HTTPS_PROXY look like a standard.
They are not. There is no RFC, no specification, and no agreement between
implementations about casing, precedence, port matching, CIDR support, or what
NO_PROXY means. Every tool you use invented its own rules, most of them
compatible enough to look identical until the day one of them is not. This is
the reference for the differences that actually break deployments, and for the
five ways NO_PROXY silently matches nothing.
Start with the variable that is spelled differently on purpose.
http_proxy is the odd one. curl accepts it in lowercase only, and the
documentation is explicit about why: "All these proxy environment variable
names except http_proxy can also be specified in uppercase, like
HTTPS_PROXY"
(everything curl).
The reason is CGI. RFC 3875 requires a CGI server to expose incoming request
headers to the script as environment variables prefixed with HTTP_. So a
request carrying a header named Proxy: produces an environment variable named
HTTP_PROXY in the script's environment — set by a remote attacker, indexed
identically to the configuration variable your HTTP client reads. Any outbound
request that script makes then goes through an address of the attacker's
choosing.
That is httpoxy, disclosed in 2016, and it collected a long list of CVEs across languages and servers rather than a single one: CVE-2016-5385 for PHP, CVE-2016-5386 for Go, CVE-2016-5387 for Apache HTTP Server, CVE-2016-5388 for Apache Tomcat, CVE-2016-1000109 for HHVM FastCGI, CVE-2016-1000110 for Python CGIHandler, CVE-2016-1000111 for Python Twisted, and CVE-2016-1000212 for lighttpd, among others (httpoxy.org).
Two different mitigations came out of it, and knowing which one your stack chose tells you what to expect.
curl took the ignore uppercase route: HTTP_PROXY is never read, full
stop. HTTPS_PROXY is safe in uppercase because a Proxy: header cannot
produce it — CGI would have to see a header literally named Https-Proxy.
Go took the detect CGI route. golang.org/x/net/http/httpproxy reads
HTTP_PROXY, HTTPS_PROXY and NO_PROXY in either case, but its Config
carries a CGI flag that "FromEnvironment infers from the presence of a
REQUEST_METHOD environment variable," and when it is set the proxy function
returns the error refusing to use HTTP_PROXY value in CGI environment
(source).
Practical consequence: if you set HTTP_PROXY in uppercase only, curl will not
use it. Set both cases, or set lowercase and let the tools that accept
uppercase find the lowercase one too.
The reference table. Everything below is from the tool's own documentation or source, linked.
| Tool | Reads | Notes |
|---|---|---|
| curl | http_proxy (lowercase only), HTTPS_PROXY, ALL_PROXY, NO_PROXY, either case |
A specific-protocol variable beats ALL_PROXY. CIDR in NO_PROXY since curl 7.86.0. Docs |
| wget | http_proxy, https_proxy, no_proxy |
Also use_proxy in .wgetrc; --no-proxy overrides. Manual |
| git | http_proxy, https_proxy, all_proxy |
http.proxy in git config overrides the environment. Per-URL form: http.<url>.proxy. git-config |
| APT | http_proxy |
"The environment variable http_proxy is supported for system wide configuration. Proxies specific to APT can be configured via the option Acquire::http::Proxy." Precedence between the two is not documented. apt-transport-http(1) |
| pip | via vendored requests | Also --proxy scheme://[user:passwd@]host:port. pip docs |
Python requests |
http_proxy, https_proxy, no_proxy, all_proxy, plus uppercase |
Environment beats session.proxies. See precedence below. Docs |
Go net/http |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY, either case |
Only when the transport uses http.ProxyFromEnvironment. CGI-aware. httpproxy |
Go x/net/proxy |
ALL_PROXY, NO_PROXY, either case |
Separate package, separate variables — this is the SOCKS path. source |
| npm | HTTP_PROXY/http_proxy for proxy; HTTPS_PROXY/https_proxy/HTTP_PROXY/http_proxy for https-proxy; NO_PROXY for noproxy |
npm config |
| Node.js core | HTTP_PROXY, HTTPS_PROXY, NO_PROXY — only when enabled |
Requires NODE_USE_ENV_PROXY=1 or --use-env-proxy. fetch() from v22.21.0 or v24.0.0; node:http/node:https from v22.21.0 or v24.5.0. Node docs |
| Java | none | System properties only: -Dhttp.proxyHost, -Dhttp.proxyPort, -Dhttp.nonProxyHosts. Oracle docs |
| Docker daemon | HTTP_PROXY, HTTPS_PROXY, NO_PROXY, either case |
Or daemon.json. Ignored by Docker Desktop. Docs |
| Docker CLI | ~/.docker/config.json proxies block |
Configures containers, not the CLI or the engine. Docs |
Two rows deserve to be read twice.
Java reads no environment variables at all. http.proxyHost and
http.proxyPort are JVM system properties, with defaults of port 80 for HTTP
and 443 for HTTPS. The bypass list is http.nonProxyHosts, and there is no
https.nonProxyHosts — the HTTPS handler reuses the HTTP one. The syntax is
also different from every other tool on this list: "a list of patterns
separated by |. The patterns may start or end with a * for wildcards." A
JVM in a container where HTTP_PROXY is set is a JVM making direct
connections. Every wrapper script that "just sets the proxy" and then launches
Java is a wrapper script that does nothing.
Node core needs opting in. Setting HTTPS_PROXY and expecting fetch() to
honor it was wrong for years and is now conditionally right: Node reads the
variables when NODE_USE_ENV_PROXY is set or --use-env-proxy is passed, with
fetch() support landing in v22.21.0 and v24.0.0 and node:http / node:https
support in v22.21.0 and v24.5.0. Below those versions, or without the flag, the
variables are inert and you need an explicit agent. undici ships
EnvHttpProxyAgent for that case, marked stable in
undici 7.4.0; attach it
as a dispatcher and it reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY for
you. Which copy of undici serves your fetch is its own trap —
Node's fetch and the undici
dispatcher works through the version skew.
Four levels, and they do not resolve the way most people assume.
proxies argument, the agent
object, the -x flag.http.proxy in git config,
Acquire::http::Proxy in apt, proxy in .npmrc, a JVM system property.The trap is level 1 versus level 3 in Python requests, and the documentation
states it plainly enough to quote: "Setting session.proxies may behave
differently than expected. Values provided will be overwritten by environmental
proxies (those returned by urllib.request.getproxies). To ensure the use of
proxies in the presence of environmental proxies, explicitly specify the
proxies argument on all individual requests"
(requests docs,
tracked as psf/requests#2018).
Read that again if you set proxies on a session object. Session-level proxies do not beat the environment in requests. Per-request proxies do. The two look like the same configuration and behave differently, which is why the same code works on a laptop and routes around the proxy in CI where the variables happen to be set.
import requests
s = requests.Session()
s.proxies = {"https": "http://login:[email protected]:823"}
# May be overridden by HTTPS_PROXY in the environment.
s.get("https://api.ipify.org/")
# Wins over the environment.
s.get("https://api.ipify.org/",
proxies={"https": "http://login:[email protected]:823"})
# Or ignore the environment entirely for this session.
s.trust_env = False
trust_env = False is the blunt instrument and often the right one in a
container: it disconnects the session from ambient configuration completely, so
what you wrote is what runs.
Go has an equivalent gap in the other direction. http.ProxyFromEnvironment is
the default only for http.DefaultTransport. Construct your own
&http.Transport{} and you get no proxy at all, silently, because the Proxy
field defaults to nil. Every "we set HTTPS_PROXY and Go ignored it" report
traces back to a custom transport.
ALL_PROXY, and the traffic it silently capturesALL_PROXY is the catch-all. curl documents it as the variable that "controls
all protocols," with a scheme-specific variable taking precedence when one
exists. requests reads it. Go's x/net/proxy — the SOCKS dialer, not the HTTP
client — reads ALL_PROXY and NO_PROXY and nothing else.
Two ways it bites.
It captures schemes you did not think about. Setting
ALL_PROXY=socks5://… to fix one tool routes FTP, and whatever else the client
supports, through the same proxy. On a metered per-gigabyte plan that is
traffic you are paying for and did not intend to send.
It selects a protocol, and therefore a DNS behavior. ALL_PROXY values
usually carry a socks5:// scheme, and under socks5:// your client resolves
hostnames locally and sends an IP to the proxy. Every hostname you touch goes
to your resolver in cleartext. The fix is one character — socks5h:// — and
which scheme your client actually
uses is worth confirming before a job runs
rather than after.
If you only need HTTP and HTTPS proxied, set http_proxy and HTTPS_PROXY and
leave ALL_PROXY unset.
NO_PROXY, and the five ways it silently failsNO_PROXY fails closed in the worst possible sense: a malformed entry does not
error, it just never matches, and your internal traffic goes out through the
proxy without a word. Here are the five.
Three spellings, three meanings, and they differ per tool.
Go's documented rule: "A domain name matches that name and all subdomains. A
domain name with a leading . matches subdomains only. For example foo.com
matches foo.com and bar.foo.com; .y.com matches x.y.com but not
y.com."
curl's documented rule: .example.com "matches both www.example.com and
home.example.com."
Node's documented set includes a third form entirely: company.com for exact
host match, .company.com for domain suffix, and *.company.com as a wildcard
domain match.
Java's nonProxyHosts uses none of these — patterns are separated by | and
wildcards are a leading or trailing *.
So NO_PROXY=.internal.example.com bypasses the proxy for the apex domain in
one client and not in another, and *.example.com is a valid wildcard in Node,
a literal string in tools that do not implement it, and a syntax error in a
|-separated Java list. Write the entry your specific client documents, and
test it.
Go documents port-qualified entries explicitly: "An IP address prefix and
domain name can also include a literal port number (1.2.3.4:80)." Node
documents company.com:8080. Not every client implements this, and the ones
that do not will treat example.com:8080 as a hostname containing a colon —
which matches nothing.
If you need a port-qualified bypass, confirm the client supports it. If you do not, leave the port off, because the bare hostname is the form with the widest support.
NO_PROXY=10.0.0.0/8 is the entry everyone writes and few verify.
httpproxy supports it: "an IP address prefix in CIDR notation
(1.2.3.4/8)."192.168.1.1-192.168.1.100 —
not CIDR.nonProxyHosts is pattern matching on names; there is no CIDR concept
in it at all.One string, four behaviors. A base image that pins an older curl is enough to turn a working bypass into a silent full-tunnel.
127.0.0.1 is not localhostMost NO_PROXY implementations compare strings. NO_PROXY=localhost does not
match a request to http://127.0.0.1:8080/, and NO_PROXY=127.0.0.1 does not
match a request to http://localhost:8080/. They are different strings, and
the matcher never resolves either one.
Go is a useful exception and a useful warning at once. Its useProxy returns
false — no proxy — when the host is literally localhost, and again when the
host parses as an IP that reports IsLoopback(). So Go bypasses loopback
whether or not you listed it, which means a bypass that works in Go proves
nothing about the same string in curl. And Go's special case covers ::1 and
the whole 127.0.0.0/8 range, which a hand-written string list usually does
not.
List all of them: localhost,127.0.0.1,::1,.svc,.cluster.local.
NO_PROXY="localhost, 127.0.0.1, .internal" produces entries with leading
spaces in clients that split on the comma and do not trim. " 127.0.0.1" is
not "127.0.0.1". Trailing commas produce an empty entry that matches nothing
and, in a few implementations, matches everything.
YAML makes this worse, because a folded or multi-line scalar can inject a newline into the middle of the value. Print the variable with delimiters before you trust it:
printf '[%s]\n' "$NO_PROXY"
If there is a space or a newline inside those brackets that you did not put there, that is your bug.
ARG vs ENV, build-time vs runtimedocker build --build-arg HTTP_PROXY=… sets the value for the build only.
It does not survive into the running container. ENV HTTP_PROXY=… in the
Dockerfile does survive — and that is usually worse, because Docker's own
documentation warns that "using environment variables for proxies embeds the
configuration into the image" and exposes sensitive information in image layers,
recommending build arguments instead because they are "only available in the
build container" and "not included in the build output"
(docs.docker.com).
An ENV HTTP_PROXY=http://login:[email protected]:823 is a credential
in a layer. It ships wherever the image ships. Pass proxy configuration at
docker run time, from a secret, or via the client config below.
Three distinct configurations, routinely confused:
| What | Configured where | Affects |
|---|---|---|
| Docker daemon | daemon.json, --http-proxy, or systemd unit environment |
Image pulls and pushes |
| Docker CLI / containers | ~/.docker/config.json proxies block |
Proxy env vars injected into containers and builds |
| The application in the container | docker run -e, Compose environment:, secret mount |
Your code's outbound requests |
The Docker documentation states the middle one's scope directly: those settings
"are used to configure proxy environment variables for containers only, and not
used as proxy settings for the Docker CLI or the Docker Engine itself." And on
the daemon side: "proxy configurations specified in the daemon.json are
ignored by Docker Desktop."
Pulling an image and running one are two different network paths. Fix the one that is failing.
Compose environment: blocks are per-service. A variable set on api is not
set on worker. Compose will also interpolate ${HTTP_PROXY} from the shell
that ran docker compose up, so the same file behaves differently on a laptop
and on a CI host — pin values in an env_file rather than relying on ambient
interpolation.
In Kubernetes, env and envFrom are per-container, not per-pod. A pod with
an application container and a sidecar needs the variables on both, and
init containers need them separately again — an init container that pulls a
dependency through a proxy while the main container talks direct is a
distinctive and confusing failure mode.
env at workflow, job and step level, with the most specific winning: a
step-level variable overrides the job level, which overrides the workflow level.
That is the documented and sensible part.
The surprise is matrices. A matrix expands into parallel jobs that each inherit
the workflow-level env independently, so a variable you expected to set once
per run is set once per matrix leg. If any part of your proxy configuration is
computed — a per-job credential, a session identifier encoded in the username —
a matrix multiplies it silently, and your concurrency accounting is off by the
size of the matrix. Set it at the step, close to the command that uses it,
where you can see it.
Bridge networking source-NATs the container's packets to the host address. The gateway sees the host, not the container, and on a CI runner or an autoscaled node that host is different every run. If you authenticate by address rather than by credential, the environment variables are correct and the connection still fails. That is a whole problem of its own — why the container's IP is not your whitelisted IP covers the Docker, Kubernetes, CI and serverless cases.
An environment variable is not a secret store. It is a string attached to a process, and the process leaks it.
docker inspect <container> prints the full Env array to anyone who can
reach the Docker socket.ps auxww and /proc/<pid>/environ expose it to the process owner and to
root on the host.ENV in a Dockerfile puts it in a layer, permanently.What to do instead: mount the credential as a file and read it at startup so it
never enters the process environment; use per-job credentials so a leak is
scoped and revocable; and keep the password out of the URL where the client
allows it — curl -U login:password -x http://gw.anonedge.com:823 rather than
embedding it in the proxy URL. Configure clients explicitly rather than
ambiently where you can; see
configuring Axios explicitly for
the pattern.
Stop guessing. Run this before the job and read what each client will actually do.
#!/usr/bin/env bash
# effective-proxy.sh — report what each client will really do.
TARGET="${1:-https://api.ipify.org/}"
echo "== environment =="
for v in http_proxy HTTP_PROXY https_proxy HTTPS_PROXY \
all_proxy ALL_PROXY no_proxy NO_PROXY; do
printf '%-12s [%s]\n' "$v" "${!v-<unset>}"
done
echo
echo "== curl =="
curl -s -o /dev/null -v "$TARGET" 2>&1 | grep -iE 'proxy|Connected to' | head -5
echo
echo "== git =="
git config --get http.proxy || echo "http.proxy <unset>"
echo
echo "== npm =="
npm config get proxy
npm config get https-proxy
npm config get noproxy
echo
echo "== python requests =="
python3 - <<'PY'
import urllib.request
print("getproxies():", urllib.request.getproxies())
PY
The square brackets in the environment section are deliberate — they make
stray whitespace visible, which is the NO_PROXY failure mode you cannot see
otherwise.
For a definitive answer on what the gateway received, ask the gateway. Echo your exit address through it and compare against a direct request:
# Through the proxy
curl -s -x "http://login:[email protected]:823" https://api.ipify.org/
# Direct
curl -s --noproxy '*' https://api.ipify.org/
Two different addresses means the proxy is in the path. The same address means it is not, whatever your variables say. Port 823 is HTTP and HTTPS rotating, 824 is SOCKS5 rotating, and 10000–20000 are sticky — the protocol and port reference has the rest, your first authenticated request has the quickstart, and troubleshooting covers what to do when the two addresses match and should not.
HTTP_PROXY in uppercase?Deliberately, because of httpoxy. CGI exposes incoming request headers as
environment variables prefixed with HTTP_, so a remote client sending a
Proxy: header creates an HTTP_PROXY variable in a CGI script's environment.
curl accepts only lowercase http_proxy so an attacker-supplied header can
never be mistaken for configuration. HTTPS_PROXY is safe in uppercase because
no header name maps to it.
HTTP_PROXY and HTTPS_PROXY?They select the proxy by the scheme of the destination URL, not by the protocol
spoken to the proxy. HTTPS_PROXY is the proxy used for https:// targets, and
its value is usually still an http:// URL, because the client opens a plain
connection to the proxy and issues CONNECT to tunnel TLS through it. Setting
only HTTP_PROXY leaves every HTTPS request going direct.
NO_PROXY not working?Five common causes, in rough order of frequency: the entry uses a wildcard form
your client does not implement; it includes a port your client does not match
on; it uses CIDR notation on a client without CIDR support, such as curl before
7.86.0; it lists localhost when the request uses 127.0.0.1 or the reverse;
or it contains leading whitespace from a comma-separated list that was never
trimmed. Print the variable with delimiters and check for spaces first.
Only if something puts them there. A Dockerfile ENV bakes them into the image
and into its layers. A --build-arg applies to the build and does not survive
into the running container. The proxies block in ~/.docker/config.json
injects them into containers, and Docker documents that the same block does not
configure the CLI or the engine. Pulling an image and running one are separate
network paths with separate configuration.
fetch() respect HTTPS_PROXY?Only when proxy support is enabled. Node reads HTTP_PROXY, HTTPS_PROXY and
NO_PROXY when NODE_USE_ENV_PROXY is set or --use-env-proxy is passed, with
fetch() support from v22.21.0 and v24.0.0 and node:http / node:https
support from v22.21.0 and v24.5.0. Without the flag, or on older versions, the
variables are inert and you need an explicit dispatcher or agent.
Because Java does not read them. Proxy configuration in the JVM is system
properties — http.proxyHost, http.proxyPort, https.proxyHost,
https.proxyPort — and the bypass list is http.nonProxyHosts, which uses
| as its separator and * for wildcards. There is no https.nonProxyHosts;
the HTTPS handler reuses the HTTP one. A shell that exports HTTP_PROXY and
then launches a JVM has configured nothing.
Environment variables are ambient configuration, and ambient configuration is the kind that is wrong in exactly one environment. Print the effective proxy per tool, assert it in your job's pre-flight, and configure clients explicitly wherever the library lets you.
Start routing on gw.anonedge.com:823. Check
the per-GB rates on the pricing section before
you size the job.