If the same HTTPS request works on your laptop and fails inside the container, the problem is almost never the network — it's that the container has a different (or empty) trust store than your machine. Your laptop trusts the corporate root CA your proxy re-signs traffic with, or ships a full CA bundle; a slim base image may ship none. The fix is to install the right root certificate into the image's trust store and, separately, to make sure your language runtime actually reads that trust store — several don't.
What does the error look like in each stack?
The underlying cause is identical; the message is not. These are the four you'll meet most often:
# Go
Get "https://api.example.com/v1/ping": tls: failed to verify certificate:
x509: certificate signed by unknown authority
# Python (requests)
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=443):
Max retries exceeded ... [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1016)
# Node.js
Error: unable to verify the first certificate
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
# curl
curl: (60) SSL certificate problem: unable to get local issuer certificate
What tripped me up the first time: the Node and curl wording ("unable to verify the first certificate") points at the server, while the Go wording points at the trust store. They are the same two failure modes wearing different hats, and you cannot tell which one you have from the application log alone.
Takeaway: the error text tells you verification failed, not whose fault it was — stop guessing and go look at the chain.
Is it a missing root, or a chain the server never sent?
There are two distinct bugs here, and patching the wrong one wastes an afternoon.
-
Missing/incomplete trust store. The server sends a perfectly good chain, but the container has no root certificate that signs it. Typical in
alpine,scratch, and hand-rolled minimal images, and universal behind a TLS-inspecting corporate proxy whose root CA only exists on managed laptops. - Broken chain from the server. The server sends its leaf certificate but omits the intermediate. Browsers and macOS/Windows paper over this by fetching the missing intermediate from the AIA extension in the certificate; OpenSSL, Go, and Python do not. So it works everywhere except your service.
Run this from inside the failing container:
openssl s_client -connect api.example.com:443 \
-servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -E 'Verify return code|^subject=|^issuer='
Read the verify code: 20 (unable to get local issuer certificate) means the chain walked up to an issuer your container doesn't trust — case 1. 21 (unable to verify the first certificate) usually means the server handed you a leaf with nothing above it — case 2. Counting what the server actually sent settles it:
openssl s_client -connect api.example.com:443 -servername api.example.com \
-showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'
One certificate for a publicly-signed endpoint is a server-side misconfiguration — fix it there (most reverse proxies want the fullchain file, not the leaf), because every other client will eventually hit it too. Also check subject= on the top certificate: if it says something like your employer's name, you're behind an inspecting proxy and need its root installed.
Takeaway: one BEGIN CERTIFICATE in the handshake means fix the server; a code 20 with a complete chain means fix the image.
How do I install a custom root CA in a container image?
Put the PEM-encoded root in the distro's anchor directory and run the distro's update command — at build time, as root, in the final stage.
# Debian/Ubuntu
FROM python:3.12-slim
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Alpine
FROM alpine:3.20
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN apk add --no-cache ca-certificates && update-ca-certificates
On RHEL-family images (including Red Hat UBI) the paths differ: drop the file in /etc/pki/ca-trust/source/anchors/ and run update-ca-trust. Two details that cause silent failures: the file extension must be .crt on Debian/Alpine or update-ca-certificates skips it, and the content must be PEM (-----BEGIN CERTIFICATE-----), not DER — convert with openssl x509 -inform der -in corp.der -out corp.crt.
Verify inside the built image rather than trusting the build log:
docker run --rm your-image sh -c \
'curl -sS -o /dev/null -w "%{http_code}\n" https://api.example.com/healthz'
Takeaway: a root CA added at runtime via a mounted volume without running the update command does nothing — the bundle is generated, not scanned on demand.
Which runtimes ignore the system trust store?
This is the part that makes the bug feel haunted: you install the root, curl inside the container works, and the app still fails. That's because not every runtime reads /etc/ssl/certs.
| Runtime | Default trust source | Override for a custom root |
|---|---|---|
Go (crypto/x509) |
System store |
SSL_CERT_FILE / SSL_CERT_DIR
|
Python ssl (stdlib) |
OpenSSL default paths | SSL_CERT_FILE |
Python requests
|
Bundled certifi
|
REQUESTS_CA_BUNDLE |
| Node.js | Bundled Mozilla list (compiled in) | NODE_EXTRA_CA_CERTS=/path/ca.pem |
| Java (JVM) |
cacerts keystore |
keytool -importcert -cacerts |
Rust (rustls + webpki-roots) |
Bundled root set |
rustls-native-certs, or a custom root store |
Node is the classic trap: it ships its own CA list, so a system-installed corporate root is invisible to it. Set NODE_EXTRA_CA_CERTS to a file (it may contain several concatenated PEM blocks, but it must be one file), or start with --use-openssl-ca to use the OpenSSL store instead. Python's requests is the same story through certifi; if you'd rather have Python defer to the OS store entirely, the truststore package does that and is the cleaner answer for corporate-proxy environments, at the cost of one more dependency and Python 3.10+.
For the JVM:
RUN keytool -importcert -noprompt -trustcacerts \
-alias corp-root -file /certs/corp-root.crt \
-cacerts -storepass changeit
Takeaway: curl succeeding inside the container proves the system store is fixed and proves nothing about your application.
What about scratch and distroless images?
A scratch image has no filesystem and therefore no CA bundle; a static Go binary that makes outbound HTTPS calls will fail on every request. Copy the bundle in from a build stage:
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
ENTRYPOINT ["/app"]
Google's distroless base and static images already include ca-certificates, which is a good reason to prefer them over scratch for anything that talks to the network — the trade-off being you cannot exec into them to debug, so keep a :debug variant handy. As of mid-2026 both remain the pragmatic default for Go and Rust services.
If your real problem is that you keep hand-copying root certs around an internal network, that's an internal-PKI problem, not a Docker problem. Smallstep's step-ca is the one that issues short-lived internal certificates with automated renewal instead of you scripting OpenSSL, and in Kubernetes cert-manager fills the same role; both add an operational component you now have to keep alive, which is a real cost for a small team. For local development only, mkcert installs a development root into your machine's store and is deliberately unsuitable for anything shared.
Takeaway: if more than one team is copying the same .crt file into images by hand, you've outgrown the manual approach.
The one fix to refuse
InsecureSkipVerify: true, verify=False, NODE_TLS_REJECT_UNAUTHORIZED=0, curl -k. Each turns a loud, correct failure into silent acceptance of any certificate, including one a man-in-the-middle generated ten seconds ago. It is reasonable for exactly one thing: proving in sixty seconds that TLS verification is the problem before you revert it. Code that ships with verification disabled is code that cannot be audited, and it will outlive the sprint that introduced it.
Takeaway: disabling verification is a diagnostic, never a fix.
FAQ
Why does curl work but my Python/Node app fail in the same container?
Because curl uses the system CA bundle while Node compiles in its own Mozilla root list and Python's requests uses the bundled certifi file. Point them at the system bundle with NODE_EXTRA_CA_CERTS or REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt.
How do I add a corporate root CA to a Docker image?
Copy the PEM file to /usr/local/share/ca-certificates/name.crt (Debian/Alpine) or /etc/pki/ca-trust/source/anchors/ (RHEL), then run update-ca-certificates or update-ca-trust in the same build stage. The .crt extension and PEM encoding are both required.
Does "x509: certificate signed by unknown authority" always mean a missing root certificate?
No. It also appears when the server omits its intermediate certificate, which browsers hide by fetching the missing link automatically and Go does not. Run openssl s_client -showcerts and count the certificates before changing your image.
Bottom line
Diagnose before you patch: openssl s_client -showcerts inside the failing container tells you in one command whether the server's chain is incomplete or your trust store is. If the chain is short, fix the server's certificate file; if the trust store is empty or missing a corporate root, install it with the distro's update command at build time. Then check your runtime separately, because Go reads the system store while Node and Python's requests ship their own — a working curl is not proof your app is fixed. Never ship with verification disabled; use it for sixty seconds to confirm the diagnosis and then take it back out.
Top comments (0)