Proxy Support and Environment Variables in Go HTTP Code
How Go's HTTP client reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY, which hosts it never proxies, why runtime changes are ignored, and how to set one yourself.

A Go program works on your laptop and fails in a customer's network with dial tcp: i/o timeout. The customer has a corporate proxy, and your client is trying to reach the internet directly. Or the opposite: a service in a cluster suddenly routes internal calls through a proxy because someone exported HTTPS_PROXY in the base image. Both come from one function, http.ProxyFromEnvironment, which behaves in a few ways that are not obvious until you have been bitten by them.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows how the function decides, using a table of URLs and an actual proxy that records what it receives. It builds on the client defaults and transport settings, and sits in the web cluster that ends in a production HTTP server. It ran on Go 1.25.6.
How the decision is made
http.DefaultTransport sets Proxy: http.ProxyFromEnvironment. For each request, that function reads three variables, each also accepted in lowercase: HTTP_PROXY for http:// URLs, HTTPS_PROXY for https:// URLs, and NO_PROXY for exceptions. The values are proxy URLs; a bare host:port is treated as http://host:port. Two rules from the documentation matter more than the rest:
- Requests to
localhostand to loopback addresses (127.0.0.1,::1) are never proxied, with or withoutNO_PROXY. That keeps local test servers working on a machine whose environment sets a proxy. - The environment is read once, the first time the function is called, and the result is cached for the life of the process. Changing the variables later with
os.Setenvdoes nothing. Tests that setHTTP_PROXYin one test and expect a different value in the next will be confused.
NO_PROXY is a comma separated list. A plain hostname matches exactly that host and its subdomains; a leading dot (.internal) matches only subdomains; an IP matches that address; a CIDR (10.0.0.0/8) matches the range; and * disables the proxy for everything. The logic comes from golang.org/x/net/http/httpproxy, which net/http vendors, and you can use that package directly if you want the same rules for your own purposes, with an explicit config instead of the process environment.
What it chooses
The program sets a proxy environment, then asks ProxyFromEnvironment about a series of requests.
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
// These must be set before the first call: the result is cached after that.
os.Setenv("HTTP_PROXY", "http://proxy.corp.test:3128")
os.Setenv("HTTPS_PROXY", "http://secure-proxy.corp.test:3129")
os.Setenv("NO_PROXY", "internal.test,.svc.cluster.local,10.0.0.0/8,192.168.1.50")
for _, target := range []string{
"http://api.example.org/v1/items",
"https://api.example.org/v1/items",
"http://localhost:8080/health",
"http://127.0.0.1:9000/",
"http://internal.test/status",
"https://billing.internal.test/charge",
"http://payments.svc.cluster.local/",
"http://svc.cluster.local/",
"http://10.4.5.6/metrics",
"http://192.168.1.50/",
"http://192.168.1.51/",
} {
req, _ := http.NewRequest("GET", target, nil)
u, err := http.ProxyFromEnvironment(req)
via := "direct"
if u != nil {
via = u.String()
}
if err != nil {
via = "error: " + err.Error()
}
fmt.Printf("%-42s -> %s\n", target, via)
}
// The cache: a later change is ignored.
os.Setenv("HTTP_PROXY", "http://other.corp.test:1")
req, _ := http.NewRequest("GET", "http://api.example.org/", nil)
u, _ := http.ProxyFromEnvironment(req)
fmt.Println("after changing HTTP_PROXY at runtime:", u)
}http://api.example.org/v1/items -> http://proxy.corp.test:3128
https://api.example.org/v1/items -> http://secure-proxy.corp.test:3129
http://localhost:8080/health -> direct
http://127.0.0.1:9000/ -> direct
http://internal.test/status -> direct
https://billing.internal.test/charge -> direct
http://payments.svc.cluster.local/ -> direct
http://svc.cluster.local/ -> http://proxy.corp.test:3128
http://10.4.5.6/metrics -> direct
http://192.168.1.50/ -> direct
http://192.168.1.51/ -> http://proxy.corp.test:3128
after changing HTTP_PROXY at runtime: http://proxy.corp.test:3128The first two lines show the scheme split: plain HTTP goes through HTTP_PROXY, HTTPS through HTTPS_PROXY. Teams often set only one and wonder why half their traffic is direct. The loopback rows are direct without any rule. internal.test matched itself and its subdomain (billing.internal.test), while .svc.cluster.local matched payments.svc.cluster.local but not the bare svc.cluster.local, which is the leading-dot rule in action. The CIDR and the single IP bypassed the proxy, and a neighbouring IP did not. The last line proves the caching: the new HTTP_PROXY was ignored, and the original is returned.
What the proxy actually sees
For a plain http:// URL, the client sends the request to the proxy with an absolute URI in the request line (GET http://service.test/path HTTP/1.1), and the proxy forwards it. For https://, it cannot, because the request is encrypted end to end. The client first sends CONNECT secure.test:443, the proxy opens a TCP tunnel, and the TLS handshake and the request travel inside it. The proxy sees only the host and port. The next program stands up a proxy that records what arrives, and uses an explicit http.ProxyURL so no environment is involved.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
)
func main() {
var seen []string
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, fmt.Sprintf("%s %s (Host header: %s)", r.Method, r.RequestURI, r.Host))
if r.Method == http.MethodConnect {
http.Error(w, "tunnels are not allowed here", http.StatusForbidden)
return
}
fmt.Fprint(w, "pretend this came from the origin")
}))
defer proxy.Close()
pu, _ := url.Parse(proxy.URL)
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(pu)}}
res, err := client.Get("http://service.test/orders?id=7")
fmt.Println("http request: err =", err, "status =", res.StatusCode)
res.Body.Close()
_, err = client.Get("https://secure.test/orders")
fmt.Println("https request: err =", err)
for _, line := range seen {
fmt.Println("proxy saw:", line)
}
}http request: err = <nil> status = 200
https request: err = Get "https://secure.test/orders": Forbidden
proxy saw: GET http://service.test/orders?id=7 (Host header: service.test)
proxy saw: CONNECT secure.test:443 (Host header: secure.test:443)The plain request arrived with the full URL in the request line, so the proxy can log, filter or cache it. The HTTPS one arrived as CONNECT secure.test:443, no path and no headers, and when the proxy refused, the client reported a proxy error instead of any HTTP status from the origin. If you see Forbidden or Proxy Authentication Required on an HTTPS call and nothing in the origin's logs, this is why: the request never left the proxy.
Setting it deliberately
Three situations come up. A fixed proxy for one client: Transport.Proxy = http.ProxyURL(u), as above, with credentials in the URL's userinfo if the proxy uses basic auth (http://user:pass@proxy:3128); keep that value in configuration, never in source. No proxy ever (a client talking only to an internal service): Proxy: nil. A per-request choice, such as routing only one vendor through a proxy with an allow-listed IP: a function func(*http.Request) (*url.URL, error) that inspects req.URL.Host.
A proxy often brings a second problem with it: TLS interception. Some corporate proxies re-sign HTTPS traffic with their own certificate authority, so Go reports x509: certificate signed by unknown authority for sites that work in a browser, which has the corporate CA installed. The fix is to add that CA to the trust store the program reads. On Linux, Go honors SSL_CERT_FILE and SSL_CERT_DIR, so pointing one of them at the bundle works without code changes, and a container image needs the CA copied in and update-ca-certificates run. Do not "fix" it with InsecureSkipVerify; that turns off the check for every connection the client makes, including the ones an attacker would like to intercept.
The same variables matter at build time. docker build does not pass your shell's proxy settings into the build by default, so a RUN go mod download that works on your machine times out in CI behind the proxy; pass them as build arguments (--build-arg HTTPS_PROXY=...), or set GOPROXY to an internal mirror, which is usually the better answer because it also makes builds repeatable.
The environment-based default applies to DefaultTransport only. A transport you build from nothing, &http.Transport{}, has Proxy == nil and ignores the environment, which is the usual reason a custom client works in a data centre and fails behind a corporate proxy. Clone the default instead (http.DefaultTransport.(*http.Transport).Clone()) or set Proxy: http.ProxyFromEnvironment yourself. And HTTP_PROXY has one safety rule in the CGI era: it is ignored when REQUEST_METHOD is set, because a web server passes the client's Proxy: header as HTTP_PROXY to CGI scripts (the "httpoxy" vulnerability). Go's implementation honors that rule, so a Go CGI program does not need a workaround. The behavior is documented in the ProxyFromEnvironment reference, and the exact matching code for NO_PROXY lives in x/net/http/httpproxy.


