Troubleshooting

Corporate proxies and developer tooling

How to point curl, git, npm, pip, Java, Go, Docker, apt, Maven and VS Code at a corporate proxy and its root CA, with the gotcha for each tool.

· 14 min read · How we verify this

Key points

  • Every tool needs two separate things: a proxy address and the corporate root CA, and the two are configured in different places with different syntax.
  • Prefer additive trust (NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE, the OS anchor directory) over replacing a bundle, and never over disabling verification.
  • Git's HTTP transport honours http_proxy, but ssh:// and git@host: ignore it entirely and need ProxyCommand with corkscrew or nc -X connect.
  • Docker needs proxy settings in three independent places: the daemon unit, build arguments, and ~/.docker/config.json for containers.

Behind a corporate proxy, two independent things must be configured for every tool you use: where to send the request (the proxy address) and which certificate authority to trust (the corporate root CA that the intercepting proxy signs with). Almost every "it works in curl but not in npm" report comes from configuring one and not the other, or from configuring them for the user's shell but not for the daemon, the build container or the language runtime that actually opens the socket. The tables below give both settings per tool, plus the specific trap each one hides.

If the proxy is doing TLS interception, understand what that implies before you start: see TLS interception and corporate root CAs. If some hosts must bypass the proxy, the exclusion rules are not portable between tools; see the no_proxy environment variable and test your list with the no_proxy tester.

The two-column summary#

ToolProxy settingRoot CA setting
curl-x/--proxy, or http_proxy, https_proxy, all_proxy--cacert, CURL_CA_BUNDLE, SSL_CERT_FILE; --proxy-cacert for the proxy's own TLS
Git (HTTP)git config --global http.proxy http://p:8080http.sslCAInfo, GIT_SSL_CAINFO
Git (SSH)ProxyCommand in ~/.ssh/confignot applicable (SSH host keys, not X.509)
npm / pnpmproxy, https-proxy, noproxy in .npmrccafile in .npmrc, or NODE_EXTRA_CA_CERTS
Yarn BerryhttpProxy, httpsProxy in .yarnrc.ymlcaFilePath in .yarnrc.yml, or NODE_EXTRA_CA_CERTS
pip--proxy, PIP_PROXY, global.proxy--cert, PIP_CERT, REQUESTS_CA_BUNDLE
Python requestsproxies= or HTTPS_PROXYREQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, verify="/path/ca.pem"
Python stdlib sslHTTPS_PROXY (via urllib)SSL_CERT_FILE, SSL_CERT_DIR
Java-Dhttps.proxyHost, -Dhttps.proxyPort, -Dhttp.nonProxyHostskeytool into cacerts, or -Djavax.net.ssl.trustStore
GoHTTPS_PROXY, NO_PROXY (via ProxyFromEnvironment)SSL_CERT_FILE, SSL_CERT_DIR (Unix, not macOS or Windows)
Docker daemon/etc/systemd/system/docker.service.d/*.conf or daemon.jsonOS trust store, plus /etc/docker/certs.d/<registry>/ca.crt
Docker build/run--build-arg HTTP_PROXY=..., ~/.docker/config.jsonCOPY the CA into the image and run the OS update command
aptAcquire::http::Proxy in /etc/apt/apt.conf.d//usr/local/share/ca-certificates/*.crt + update-ca-certificates
dnf / yumproxy= in /etc/dnf/dnf.conf/etc/pki/ca-trust/source/anchors/ + update-ca-trust extract
Maven<proxies> in ~/.m2/settings.xmlJVM truststore (Maven has no CA setting of its own)
GradlesystemProp.https.proxyHost in gradle.propertiesJVM truststore
VS Codehttp.proxy, http.proxySupporthttp.systemCertificates, plus NODE_EXTRA_CA_CERTS for extensions

curl and Git#

curl reads http_proxy in lower case only. This is deliberate: in a CGI environment the request header Proxy: becomes the environment variable HTTP_PROXY, so honouring the upper-case form let a remote attacker redirect a server's outbound traffic (CVE-2016-5385, "httpoxy"). HTTPS_PROXY and NO_PROXY are read in either case. If your onboarding doc tells you to export HTTP_PROXY and only that, plain HTTP requests from curl will not be proxied.

The second curl subtlety is that there are two possible TLS sessions: the one to the origin and, if the proxy itself speaks HTTPS, the one to the proxy. --cacert covers the first, --proxy-cacert the second (curl 7.52.0 added HTTPS proxy support and this option family). Full detail lives in curl through a proxy.

Git's HTTP transport is libcurl, so it inherits all of the above, and adds per-URL configuration, which is the cleanest way to proxy only what needs proxying:

bash
git config --global http.https://github.com/.proxy http://proxy.corp:8080
git config --global http.sslCAInfo /etc/ssl/certs/corp-root.pem
git config --global http.proxyAuthMethod negotiate   # for Kerberos-authenticated proxies

The gotcha is the SSH transport. A remote of the form git@github.com:org/repo.git or ssh://git@host/repo never touches libcurl, never reads http_proxy, and never reads http.proxy. It opens TCP port 22 directly, which a corporate egress firewall almost always blocks. The fix is an SSH ProxyCommand that performs an HTTP CONNECT on your behalf:

text
Host github.com
  HostName ssh.github.com
  Port 443
  User git
  ProxyCommand corkscrew proxy.corp 8080 %h %p ~/.ssh/proxyauth

nc -X connect -x proxy.corp:8080 %h %p does the same thing with OpenBSD netcat, and socat - PROXY:proxy.corp:%h:%p,proxyport=8080 with socat. Note the Port 443 above: most proxies restrict CONNECT to 443 by policy, so tunnelling to port 22 is refused with 403 even when the tunnel itself works. GitHub's ssh.github.com:443 endpoint exists precisely for this case. See HTTP CONNECT tunnelling for what the proxy is actually doing, and proxy authentication if the tunnel returns 407.

Node.js: npm, yarn and pnpm#

npm and pnpm both read .npmrc; Yarn Berry reads .yarnrc.yml and does not read .npmrc at all.

ini
# ~/.npmrc
proxy=http://proxy.corp:8080
https-proxy=http://proxy.corp:8080
noproxy=.corp.example,localhost,127.0.0.1
cafile=/etc/ssl/certs/corp-root.pem

Note that https-proxy takes an http:// URL: that is the scheme of the connection to the proxy, not the scheme of the target. Note also noproxy (one word); older documentation shows no-proxy.

The cafile gotcha: npm's cafile replaces the trusted CA set rather than adding to it. If the proxy intercepts everything, that is harmless. If it intercepts selectively (common when only certain categories are inspected), every non-intercepted host then fails with unable to get local issuer certificate, and the failure appears to be intermittent and registry-specific. The fix is either to concatenate the corporate root onto a copy of the public bundle, or to stop using cafile and use NODE_EXTRA_CA_CERTS instead, which is additive: Node keeps its built-in Mozilla root list and appends the PEM file you name.

bash
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pem

Two constraints on that variable: the file must be PEM (one or more -----BEGIN CERTIFICATE----- blocks, not DER or PKCS#12), and it is read once at process start, so a change requires restarting the process, including any language server or long-lived dev server. It applies to every Node process, which means npm, pnpm, Yarn, node-fetch, Electron apps and VS Code extensions are all fixed by the same export.

pip, requests and certifi#

bash
pip install --proxy http://proxy.corp:8080 --cert /etc/ssl/certs/corp-root.pem requests
# persistent equivalents
pip config set global.proxy http://proxy.corp:8080
pip config set global.cert /etc/ssl/certs/corp-root.pem

PIP_PROXY and PIP_CERT are the environment-variable forms (pip maps any option to PIP_<OPTION>). pip 22.2 added --use-feature=truststore, which validates against the operating system trust store instead of the vendored certifi bundle; on a machine where the corporate root is already installed system-wide, that removes the need for --cert entirely. Check your pip version's changelog for whether it is still opt-in.

The requests gotcha is that SSL_CERT_FILE does nothing. requests verifies against certifi, a Python package containing a snapshot of the Mozilla root store, and does not consult OpenSSL's default paths. So the stdlib (urllib.request, http.client, anything on ssl.create_default_context()) is fixed by SSL_CERT_FILE, and requests is fixed by REQUESTS_CA_BUNDLE. Setting only one of the two produces the situation where urllib works and requests fails inside the same interpreter. requests also falls back to CURL_CA_BUNDLE if REQUESTS_CA_BUNDLE is unset. Do not append your root to the file returned by certifi.where(): the next pip install --upgrade certifi silently reverts it.

Java, Maven and Gradle#

The JVM ignores http_proxy and https_proxy entirely unless you set -Djava.net.useSystemProxies=true. Configuration is by system property:

bash
JAVA_TOOL_OPTIONS="-Dhttps.proxyHost=proxy.corp -Dhttps.proxyPort=8080 \
  -Dhttp.proxyHost=proxy.corp -Dhttp.proxyPort=8080 \
  -Dhttp.nonProxyHosts='localhost|127.*|*.corp.example'"

Three things bite here. First, nonProxyHosts is pipe separated, not comma separated, unlike every other tool on this page; a comma-separated list parses as one long hostname and silently excludes nothing. Second, there is no https.nonProxyHosts property: the HTTPS protocol handler reads http.nonProxyHosts. Third, since 8u111 the JDK disables HTTP Basic authentication on CONNECT tunnels by default, controlled by jdk.http.auth.tunneling.disabledSchemes, whose default value is Basic. Against a proxy that requires Basic credentials for HTTPS, this appears as an authentication failure that no amount of correct credentials fixes. The property can be cleared (-Djdk.http.auth.tunneling.disabledSchemes=""), but doing so puts credentials on the wire in a form the proxy operator can read; prefer a proxy that supports Negotiate.

Trust goes into the JVM truststore with keytool:

bash
keytool -importcert -noprompt -alias corp-root \
  -file corp-root.pem \
  -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit

changeit is the documented default password for cacerts. The path is $JAVA_HOME/lib/security/cacerts on Java 9 and later, and $JAVA_HOME/jre/lib/security/cacerts on Java 8. The gotcha: a JDK upgrade replaces this file, so the fix evaporates on the next patch cycle. On a developer machine, import into a copy and point -Djavax.net.ssl.trustStore at it; in CI, do the import in the image build so it is reproducible.

Maven takes proxies from ~/.m2/settings.xml (<proxies><proxy><nonProxyHosts>, also pipe separated) but has no CA configuration of its own: it uses the JVM truststore, so MAVEN_OPTS is where the truststore properties go. Gradle wants systemProp. prefixes in gradle.properties:

properties
systemProp.https.proxyHost=proxy.corp
systemProp.https.proxyPort=8080
systemProp.http.nonProxyHosts=localhost|*.corp.example

The Gradle daemon caches system properties for its lifetime, so run gradle --stop after changing them or you will test a stale configuration.

Go#

net/http honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY through http.ProxyFromEnvironment, but only when the transport uses it. http.DefaultTransport does; a hand-rolled &http.Transport{TLSClientConfig: ...} does not, unless you set Proxy: http.ProxyFromEnvironment explicitly. That single omitted line is the most common reason a Go service ignores the proxy that every other process on the host is using. Like curl, Go ignores upper-case HTTP_PROXY when it detects a CGI environment.

GOPROXY is not an HTTP proxy. It is the URL of a Go module proxy (default https://proxy.golang.org,direct), a completely different mechanism that serves module zips over HTTPS. Setting GOPROXY=http://proxy.corp:8080 breaks module resolution without affecting outbound HTTP at all. If your organisation runs an internal module mirror, GOPROXY points at that; the corporate HTTP proxy still comes from HTTPS_PROXY. Pair GOPROXY with GOPRIVATE or GONOSUMDB so internal modules skip the public checksum database.

For trust, Go's crypto/x509 reads SSL_CERT_FILE and SSL_CERT_DIR on Linux and the BSDs, and ignores them on macOS and Windows, where the platform trust store is used and the certificate must be installed into the OS keychain or certificate store instead. This is why a Go tool works on the Linux CI runner but not on the developer's Mac after the "same" fix.

Docker: three places, and you usually need all three#

What is failingWhere the setting goes
docker pull (the daemon fetches images)/etc/systemd/system/docker.service.d/http-proxy.conf, or proxies in /etc/docker/daemon.json (Docker 23.0 and later)
RUN apt-get inside a buildbuild args: --build-arg HTTP_PROXY=... --build-arg HTTPS_PROXY=...
Network calls from a running containerproxies.default in ~/.docker/config.json, which the CLI injects as environment variables
ini
# /etc/systemd/system/docker.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://proxy.corp:8080"
Environment="HTTPS_PROXY=http://proxy.corp:8080"
Environment="NO_PROXY=localhost,127.0.0.1,registry.corp.example,.svc,10.0.0.0/8"

followed by systemctl daemon-reload && systemctl restart docker. HTTP_PROXY, HTTPS_PROXY, NO_PROXY and their lower-case forms are predefined build arguments: they need no ARG line in the Dockerfile and are excluded from the image metadata, so they do not leak into docker history. They are still visible in the build cache key considerations, so keep credentials out of them.

Trust is separate again. The daemon uses the host OS trust store for registry TLS, plus /etc/docker/certs.d/<registry-host>/ca.crt for a specific registry. A container has its own trust store, and nothing on the host affects it: the CA must be copied in and registered.

dockerfile
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt \
    REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt

On Debian and Ubuntu the file must have a .crt extension and contain PEM, or update-ca-certificates skips it without an error. On RHEL family images the path is /etc/pki/ca-trust/source/anchors/ and the command is update-ca-trust extract.

Do not forget NO_PROXY for in-cluster and in-network destinations. A daemon that proxies its calls to an internal registry, or a container that proxies its calls to 10.0.0.1, produces confusing 502s from the proxy rather than a clean connection error.

Operating system package managers#

apt does read http_proxy, but you almost always run it under sudo, and a default sudoers with env_reset strips the variable. Put it in a file instead:

text
# /etc/apt/apt.conf.d/95proxies
Acquire::http::Proxy  "http://proxy.corp:8080/";
Acquire::https::Proxy "http://proxy.corp:8080/";
Acquire::https::Proxy::deb.corp.example "DIRECT";

dnf and yum take proxy=, proxy_username= and proxy_password= in /etc/dnf/dnf.conf or per repository in /etc/yum.repos.d/*.repo, where proxy=_none_ is the per-repository bypass. sslcacert= sets a CA per repository.

Why additive trust beats disabling verification#

Disabled verification is also sticky: strict-ssl=false gets committed, copied into the Dockerfile, inherited by the base image, and ends up in production where there is no proxy and no reason for it.

Never do this#

  • npm config set strict-ssl false and yarn config set enableStrictSsl false. Accepts any certificate for every registry request, including for packages that execute install scripts.
  • NODE_TLS_REJECT_UNAUTHORIZED=0. Disables verification process-wide, including for your application's own outbound calls, and Node prints a warning that everyone learns to ignore.
  • GIT_SSL_NO_VERIFY=true and git config http.sslVerify false. Makes git clone accept a substituted repository.
  • PYTHONHTTPSVERIFY=0. Turns off certificate verification for the stdlib globally.
  • pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org. Disables both TLS verification and the host check for the exact hosts from which you are about to execute arbitrary setup code.
  • curl -k / --insecure in a script. Fine for one interactive diagnostic keystroke, a defect when saved to a file.

Each of these is a two-minute fix that costs a security review later. The additive equivalent takes the same two minutes.

Failure modes and what they mean#

SymptomLikely causeFix
SELF_SIGNED_CERT_IN_CHAIN / unable to get local issuer certificate (npm)Interception, no corporate root in Node's trustNODE_EXTRA_CA_CERTS
SSLError: certificate verify failed: unable to get local issuer certificate (pip)certifi bundle lacks the rootPIP_CERT or REQUESTS_CA_BUNDLE
PKIX path building failed: unable to find valid certification path (Java)Root not in cacertskeytool -importcert
x509: certificate signed by unknown authority (Go, Docker)OS trust store lacks the rootupdate-ca-certificates, or SSL_CERT_FILE on Linux
ssh: connect to host github.com port 22: Connection timed outSSH is not proxiedProxyCommand to ssh.github.com:443
407 Proxy Authentication Required from one tool onlyThat tool lacks credentials, or its auth scheme is disabledSee proxy authentication; check jdk.http.auth.tunneling.disabledSchemes on the JVM
Works in the shell, fails in the IDE or a serviceEnvironment not inherited by a GUI app or systemd unitSet in the unit file or the app's own settings
Works for public hosts, fails for internal onesInternal host is being sent to the proxyAdd it to no_proxy in every tool's own syntax

The last two are worth internalising. Environment variables set in ~/.bashrc do not reach a desktop-launched IDE, a systemd service, a cron job, or a container. When a fix "does not stick", check which process is making the connection before changing the setting again.

Frequently asked questions#

Why does npm fail with SELF_SIGNED_CERT_IN_CHAIN when curl works?#

Because curl and Node use different trust stores. curl on Linux typically validates against the OS bundle at /etc/ssl/certs/, which your administrator may have already populated with the corporate root, while Node ships its own compiled-in copy of the Mozilla root list and ignores the OS store. Set NODE_EXTRA_CA_CERTS to the corporate root PEM and restart the process.

What is the difference between NODE_EXTRA_CA_CERTS and npm's cafile?#

NODE_EXTRA_CA_CERTS adds certificates to Node's built-in trust store and applies to every Node process. npm's cafile replaces the trusted set for npm only, so any host not signed by the CA in that file starts failing. Use NODE_EXTRA_CA_CERTS unless you deliberately want to pin npm to a single CA.

Why does git clone over SSH ignore my proxy settings?#

The SSH transport is not HTTP and does not read http_proxy, https_proxy or http.proxy. It opens a direct TCP connection to port 22. To route it through an HTTP proxy, configure ProxyCommand in ~/.ssh/config using corkscrew, nc -X connect or socat, and target a provider endpoint on port 443 because most proxies only allow CONNECT to 443.

Does GOPROXY configure an HTTP proxy for Go?#

No. GOPROXY is the URL of a Go module proxy that serves module downloads, and setting it to a corporate HTTP proxy address breaks module resolution. Go's outbound HTTP proxy comes from HTTP_PROXY and HTTPS_PROXY, honoured by http.ProxyFromEnvironment.

Why do I need proxy settings in three places for Docker?#

Because three different processes make network calls: the daemon (pulling images), the builder (RUN steps inside a build), and the container at runtime. They do not share configuration. The daemon reads its systemd unit or daemon.json, the build reads build arguments, and containers get environment variables injected from ~/.docker/config.json.

Is adding the corporate root CA safe?#

It is the correct action if your organisation operates the proxy, and it does not grant the proxy any capability it does not already have. It does mean that anyone able to issue certificates from that CA can impersonate any site to you, so the root should come from an internal distribution channel you trust, not from a certificate you scraped off a browser error page. TLS interception and corporate root CAs covers the trust model in detail.

How do I find the proxy address if nobody will tell me?#

Check for a PAC file: on Windows and macOS the system proxy settings usually name a .pac URL, and WPAD may be publishing one at http://wpad.<domain>/wpad.dat. Download it and read the FindProxyForURL function, or evaluate it with the PAC file tester. See PAC files and WPAD for how resolution order works.

Why does my fix work locally but not in CI?#

CI runners execute as a different user, without your shell profile, often inside a container that has its own trust store. Configuration that lives in ~/.npmrc, ~/.m2/settings.xml or an interactive export does not exist there. Put the CA import and the proxy variables into the image build or the pipeline environment block so they are versioned alongside the code.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. curl manual, proxy options and environment variables
  2. git-config documentation (http.proxy, http.sslCAInfo)
  3. npm config reference (proxy, cafile, strict-ssl)
  4. Node.js CLI documentation, NODE_EXTRA_CA_CERTS
  5. pip user guide, using a proxy server and SSL options
  6. Oracle JDK networking properties
  7. Go net/http ProxyFromEnvironment
  8. Docker documentation, configure the daemon and client to use a proxy
  9. Apache Maven settings reference, proxies
  10. CVE-2016-5385 httpoxy

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in troubleshooting proxies#