Proxy environment variables: HTTP_PROXY, HTTPS_PROXY traps

    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.

    Proxy environment variables HTTP_PROXY and HTTPS_PROXY: the casing rule

    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.

    Which tools read which variable

    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_PROXYonly 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.

    Precedence

    Four levels, and they do not resolve the way most people assume.

    1. Explicit per-request configuration — the proxies argument, the agent object, the -x flag.
    2. Explicit process configurationhttp.proxy in git config, Acquire::http::Proxy in apt, proxy in .npmrc, a JVM system property.
    3. Environment variables.
    4. Nothing — direct connection.

    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 captures

    ALL_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 fails

    NO_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.

    Subdomain matching is not wildcard matching

    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.

    Ports are part of the match in some clients and not others

    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.

    CIDR ranges are not supported where you assume they are

    NO_PROXY=10.0.0.0/8 is the entry everyone writes and few verify.

    • Go's httpproxy supports it: "an IP address prefix in CIDR notation (1.2.3.4/8)."
    • curl supports it, but only from 7.86.0 onward. On an older curl the string matches nothing and every request to that range goes through the proxy.
    • Node documents an IP range syntax instead — 192.168.1.1-192.168.1.100 — not CIDR.
    • Java's 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 localhost

    Most 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.

    Whitespace and trailing commas

    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.

    Containers and CI: where the variables actually land

    Dockerfile ARG vs ENV, build-time vs runtime

    docker 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.

    The Docker daemon's proxy config is separate from the container's

    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 and Kubernetes

    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.

    GitHub Actions

    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.

    Your container's egress IP is not your whitelisted IP

    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.

    Credentials in environment variables leak

    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.
    • CI failure paths echo effective configuration constantly, and masking only covers the exact literal registered as a secret — not a URL-encoded variant, not a substring.
    • Crash reporters and APM agents capture request configuration in exception payloads by default.
    • 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.

    Frequently asked questions

    Why does curl ignore 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.

    What is the difference between 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.

    Why is 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.

    Do proxy environment variables work in Docker containers?

    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.

    Does Node.js 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.

    Why does my Java application ignore the proxy environment variables?

    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.