To use a proxy with Go net/http, either set HTTP_PROXY, HTTPS_PROXY, and NO_PROXY before the process starts and use http.ProxyFromEnvironment, or parse one explicit proxy URL and assign http.ProxyURL(proxyURL) to a cloned http.Transport. Reuse the transport and client, set bounded timeouts, close every response body, and verify the exit before sending application traffic.
Scope: this tutorial covers outbound HTTP and HTTPS requests made by Go clients. It does not build a proxy server or claim that all third-party Go SDKs use http.DefaultClient. Review protocol choices in HTTP, HTTPS, and SOCKS5 proxies and find adjacent client examples in the Proxy Setup and Developer Guides hub.
Create a small acceptance program
Use a maintained Go toolchain approved by the project. Start in an empty module so the first result reflects the standard library and the selected proxy, not an SDK with its own transport.
mkdir proxycheck
cd proxycheck
go mod init example.com/proxycheck
go version
Record the operating system and Go version. The public net/http documentation describes the default transport, environment proxy behavior, supported proxy schemes, connection reuse, and concurrency guarantees. Use that as the source of truth for the toolchain version in production.
ProxyFromEnvironment: use process configuration intentionally
Go’s default transport uses http.ProxyFromEnvironment. It reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY, including lowercase forms. The proxy value can be a complete URL; if a compatible implementation accepts a bare host:port, an HTTP scheme is assumed, but an explicit URL is easier to review.
# macOS or Linux
export HTTP_PROXY='http://HOST:PORT'
export HTTPS_PROXY='http://HOST:PORT'
export NO_PROXY='localhost,127.0.0.1,.internal.example'
go run .
# Windows PowerShell
$Env:HTTP_PROXY = 'http://HOST:PORT'
$Env:HTTPS_PROXY = 'http://HOST:PORT'
$Env:NO_PROXY = 'localhost,127.0.0.1,.internal.example'
go run .
Set environment values before the process begins and do not treat them as a per-request switch. The standard implementation caches environment-derived proxy behavior, and changing variables after requests start is an unreliable configuration strategy. For different routes, create explicit clients with separate transports.
Be careful in CGI-like environments: Go protects against unsafe use of uppercase HTTP_PROXY when the REQUEST_METHOD environment variable indicates CGI. This behavior prevents an inbound Proxy header from becoming outbound proxy configuration. A server launched under CGI should use an explicit, reviewed transport instead of trying to defeat that protection.
Clone the default transport instead of rebuilding it blindly
The default transport contains sensible dial, keep-alive, TLS-handshake, and idle-connection settings. Clone it, make the intended proxy and timeout changes, and reuse it:
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyFromEnvironment
transport.MaxIdleConns = 100
transport.MaxIdleConnsPerHost = 10
transport.IdleConnTimeout = 90 * time.Second
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
response, err := client.Get("https://api.ipify.org?format=json")
if err != nil {
panic(err)
}
defer response.Body.Close()
fmt.Println(response.Status)
}
The type assertion is appropriate for the standard default transport in an application that controls process initialization. A library should accept an http.RoundTripper or *http.Client from its caller instead of assuming ownership of global defaults.
Use ProxyURL for an explicit per-client route
For a route owned by one service component, parse the URL once and assign http.ProxyURL:
proxyURL, err := url.Parse("http://HOST:PORT")
if err != nil {
return fmt.Errorf("parse proxy URL: %w", err)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(proxyURL)
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
Validate configuration before parsing: allow only expected schemes, require a host and port, and reject fragments or destination-like paths. The scheme describes the connection to the proxy. An HTTP proxy can tunnel HTTPS destinations with CONNECT.
An explicit transport ignores NO_PROXY unless the proxy function implements bypass logic. If bypass is required, either use ProxyFromEnvironment with a controlled process environment or write a small reviewed proxy-selection function that returns nil, nil only for exact approved hosts.
Proxy credentials and URL user information
The Transport.Proxy documentation states that when the returned proxy URL contains user information, the transport sends username/password in a Proxy-Authorization header. Construct the URL from a protected secret, do not hardcode it, and never log proxyURL.String() after adding credentials.
proxyURL, err := url.Parse("http://HOST:PORT")
if err != nil {
return fmt.Errorf("invalid proxy URL: %w", err)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(proxyURL)
If authentication is required, load USER and PASSWORD from the project secret provider and assign URL user information with url.UserPassword before installing the proxy function. User information does not make the secret safe to print. Load USER and PASSWORD from the project’s secret provider, limit their process scope, and rotate a password exposed in logs or panic output.
A 407 belongs to proxy authentication. A 401 returned after the tunnel reaches a private API belongs to the destination. Keep proxy credentials separate from Authorization headers on the request. The authentication guide compares this model with source-IP allowlisting.
NO_PROXY: make bypass behavior explicit
The official httpproxy documentation describes comma-separated bypass entries, including IP prefixes, CIDR notation, domain names, subdomain forms, and optional ports. Loopback addresses are bypassed as a special case. Exact matching details matter, so test the host strings the application actually requests.
A narrow example is NO_PROXY=localhost,127.0.0.1,.internal.example. The leading dot is a deliberate subdomain rule. Do not use a broad corporate suffix if only one service must bypass. Every bypass changes where traffic exits and what evidence a regional test can claim.
Log whether a destination was selected for proxy or direct routing, but do not log the credential-bearing URL. You can call the proxy function with a sanitized request in a unit test and assert whether the returned URL is nil.
Transport reuse, bodies, and connection pooling
The net/http package documentation says clients and transports are safe for concurrent use and should be reused. Creating a transport per request prevents effective pooling and can exhaust ephemeral ports. Keep one client per stable route and policy.
Close every response body. When the application needs connection reuse, read or discard the remaining body according to its protocol and size policy before closing. Do not read an unbounded error page merely to save a connection.
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("proxy request: %w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return fmt.Errorf("read bounded response: %w", err)
}
Use MaxConnsPerHost or application-level semaphores to enforce approved concurrency. A large proxy pool does not change the aggregate load placed on one destination.
Use layered timeouts and cancellation
http.Client.Timeout bounds the complete exchange. The transport can also bound proxy/gateway connection, TLS handshake, response headers, and idle connections. A request context lets the caller cancel when the job is no longer needed.
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyFromEnvironment
transport.DialContext = dialer.DialContext
transport.TLSHandshakeTimeout = 10 * time.Second
transport.ResponseHeaderTimeout = 20 * time.Second
client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
Do not retry every error. DNS failures, 407, certificate failures, 403, and many application responses require correction or review, not more traffic. Retry only operations known to be safe and idempotent, with a small limit, backoff, jitter, and respect for Retry-After.
Exit verification with bounded JSON parsing
Use one neutral HTTPS endpoint, require a 2xx response, check the content type when available, and decode a small JSON object:
type ipResult struct {
IP string `json:"ip"`
}
response, err := client.Get("https://api.ipify.org?format=json")
if err != nil {
return fmt.Errorf("exit check request: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("exit check status: %s", response.Status)
}
var result ipResult
decoder := json.NewDecoder(io.LimitReader(response.Body, 4096))
if err := decoder.Decode(&result); err != nil {
return fmt.Errorf("decode exit check: %w", err)
}
if net.ParseIP(result.IP) == nil {
return fmt.Errorf("exit check returned invalid IP")
}
fmt.Println(map[string]string{"exit_ip": result.IP})
Expected observation: the request succeeds with TLS validation enabled, returns a valid public address within the size/time limits, and the address matches the assigned proxy exit rather than the intentionally direct baseline.
CONNECT troubleshooting and proxy responses
For an HTTPS destination over an HTTP proxy, the transport asks the proxy to establish a CONNECT tunnel. A 407 during CONNECT means the gateway rejected authentication. A 502 or timeout can mean the gateway could not reach the destination, the destination refused the route, or an intermediate network failed.
Current Go exposes OnProxyConnectResponse on http.Transport for inspecting a proxy response before the standard success check. Use it only for sanitized status/header diagnostics; never record Proxy-Authorization or arbitrary bodies.
transport.OnProxyConnectResponse = func(
ctx context.Context,
proxyURL *url.URL,
connectRequest *http.Request,
connectResponse *http.Response,
) error {
slog.InfoContext(ctx, "proxy connect",
"proxy_host", proxyURL.Host,
"target_host", connectRequest.Host,
"status", connectResponse.StatusCode)
return nil
}
Confirm the exact Go version supports the field before using it. The core client should still return failures normally; diagnostics must not convert a rejected CONNECT response into success.
Go proxy troubleshooting by symptom
| Signal | Likely boundary | First check |
|---|---|---|
proxyconnect tcp name/route error |
Gateway DNS, address, port, firewall | Validate the configured proxy URL and sanitized host |
| 407 during CONNECT | Proxy authentication | Check URL user info, account state, or allowlisted public IP |
x509 error |
TLS hostname, clock, trust chain, or interception | Inspect certificate evidence; do not use insecure skip verify |
| Request unexpectedly direct | NO_PROXY, nil proxy function, or SDK transport |
Inspect the exact client and proxy decision for the host |
| Environment change ignored | Cached environment configuration | Set variables before process start or create an explicit client |
| Too many open connections | Transport/body lifecycle | Reuse transport and close bounded bodies |
| Only one destination returns 403/429 | Destination policy or rate limit | Stop, preserve status, and review the approved workflow |
Follow the layered proxy test and common proxy error guide before cycling endpoints. Record Go version, sanitized gateway, destination host, stage, status, and duration without logging secrets or sensitive response content.
Pass clients into libraries instead of changing globals
A reusable package should accept *http.Client or an interface implemented by it. The application owns proxy selection, timeouts, authentication, and connection lifetime. This keeps tests deterministic and prevents one package from changing global transport for unrelated requests.
Create separate named clients for direct internal traffic and proxied external traffic when both are required. Make the choice at a clear call boundary. Do not mutate one transport while concurrent requests are running.
Responsible-use boundary: send only authorized requests, follow destination terms and aggregate rate limits, bound concurrency and retries, prefer official APIs where required, and stop on explicit denial. A proxy route remains accountable application traffic.
Select a stable Go route after the client is observable
Record protocol, country, authentication model, exact bypasses, concurrent requests, client lifetime, and measured connect/response times. If the approved service needs an exclusive stable exit, compare those requirements with current private proxy options only after the direct baseline, proxied exit check, application smoke test, and cleanup behavior are repeatable.

