BlueByte
curl: (60) SSL certificate problem: unable to get local issuer certificateFixed

curl: (60) SSL certificate problem: unable to get local issuer certificate

By Haneul SeoUpdated September 19, 20267 min

Hi, it's BlueByte. A curl https://… that works from your laptop fails on a build agent, inside a container, or on the office network with curl: (60) SSL certificate problem: unable to get local issuer certificate. The server is up — a browser opens the same URL without a warning — but curl refuses the handshake. We'll walk through what the message means, the four places the missing issuer can hide, the two commands that tell them apart, the fix for each, and how to keep the next runner from failing the same way.

What "local issuer" means, and the four messages that share exit code 60

Before curl transfers a byte over TLS it checks, in the manual's words, that the certificate contains the right name which matches the hostname in the URL, and that it has been signed by a CA certificate present in the cert store — the trusted roots curl was built with or told to use. Error 60 is the second check failing; the suffix comes from the TLS backend (OpenSSL on most Linux builds) and says which link broke:

curl: (60) SSL certificate problem: unable to get local issuer certificate
curl: (60) SSL certificate problem: self-signed certificate in certificate chain
curl: (60) SSL certificate problem: self-signed certificate
curl: (60) SSL certificate problem: certificate has expired

"unable to get local issuer certificate" means curl walked the chain the server sent, reached a certificate whose issuer it could not find in its store, and stopped. All four exit with status 60 — Peer certificate cannot be authenticated with known CA certificates — so only the text tells them apart.

Four places the issuer goes missing

The server sends an incomplete chain. A site certificate is signed by an intermediate CA, which is signed by a root in your store. The server is supposed to send the intermediate too; if it sends only the leaf, curl cannot bridge leaf to root. Browsers hide this mistake by fetching the missing intermediate themselves; curl and OpenSSL do not. "Works in Chrome, fails in curl" is the classic sign.

A TLS-inspecting proxy re-signs the site. Firewalls that decrypt HTTPS present a certificate issued by the company's own CA. Managed laptops trust it because IT pushed that CA into the OS store; curl in a container, a CI runner or a fresh VM has never seen it.

curl is reading a different store than you think. CURL_CA_BUNDLE, SSL_CERT_FILE or SSL_CERT_DIR point at a file that lacks the root; a curl from conda, Git for Windows or a vendored toolchain ships its own bundle; on Windows, curl.exe also picks up any curl-ca-bundle.crt next to the binary, in the working directory or along %PATH%.

The store is old. A base image or long-lived VM whose ca-certificates package predates the root the site now chains to. Same message, no proxy involved.

Two commands that tell the causes apart

First, ask curl which store it used. With -v, the OpenSSL backend prints the file and directory right before the failure:

curl -v https://incomplete-chain.badssl.com/ -o /dev/null 2>&1 | grep -E 'CAfile|CApath|certificate problem'
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: /etc/ssl/certs
* SSL certificate problem: unable to get local issuer certificate

If CAfile is not the path you expected, jump to Fix 3. Otherwise look at what the server actually sent:

echo | openssl s_client -connect incomplete-chain.badssl.com:443 -servername incomplete-chain.badssl.com 2>/dev/null | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
 0 s:CN = *.badssl.com
   i:C = US, O = Let's Encrypt, CN = …
    Verify return code: 21 (unable to verify the first certificate)

One certificate at depth 0 and nothing at depth 1 means the intermediate is missing — cause one. An issuer naming your company instead of a public CA means a proxy re-signed it — cause two. A complete public chain that still fails means the root is not in the store curl is reading — cause three or four.

Fix 1: the server sends only the leaf

The real fix is on the server: serve the file that includes the intermediates (fullchain.pem from certbot, or the bundle your CA ships) and reload. If you do not own the server and need the transfer today, fetch the missing issuer from the URL in the leaf's Authority Information Access extension, append it to a copy of your bundle, and pass that with --cacert:

HOST=incomplete-chain.badssl.com
echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -outform PEM > leaf.pem
AIA=$(openssl x509 -in leaf.pem -noout -ext authorityInfoAccess | grep -oE 'http[^ ]+' | grep -vi ocsp | head -1)
curl -sS "$AIA" | openssl x509 -inform DER -out issuer.pem
cat /etc/ssl/certs/ca-certificates.crt issuer.pem > bundle.pem
curl -sS --cacert bundle.pem "https://$HOST/" -o /dev/null -w '%{http_code} %{ssl_verify_result}\n'
200 0

The issuer is still checked against the root in your store — you only supplied the link the server forgot. Most CAs publish it in DER form; if openssl x509 complains, drop -inform DER. Keep the bundle scoped to that host and file the server-side fix.

Fix 2: trust the inspecting proxy's CA where curl looks

Get the CA certificate from IT, add it to the OS trust source the default bundle is built from, and rebuild:

# Debian / Ubuntu — the file must end in .crt
sudo cp corp-root-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# RHEL / Fedora / Amazon Linux
sudo cp corp-root-ca.pem /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

On Debian the command reports how many certificates it added; a 0 added usually means the file did not end in .crt. For a single command, --cacert corp-root-ca.pem works without touching the system. The --ca-native flag (curl 8.2.0 and later) adds the operating system's store to the search, but what it can read depends on the TLS backend: with OpenSSL it works on Windows, and on Apple systems only when libcurl is built with Apple SecTrust (curl 8.17.0). curl --version names your backend.

Fix 3 and 4: point curl at a store that has the root

env | grep -E 'CURL_CA_BUNDLE|SSL_CERT_FILE|SSL_CERT_DIR'

Unset a stale variable or point it at the distribution bundle — /etc/ssl/certs/ca-certificates.crt on Debian and Ubuntu, /etc/pki/tls/certs/ca-bundle.crt on RHEL. A missing or empty path gives a different error, curl: (77) error setting certificate file: a bad path, not a bad chain. For an old store, upgrade the package:

sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates

On Fedora and RHEL it is sudo dnf upgrade ca-certificates. And yes, -k/--insecure makes the message go away by skipping the check; the manual's own line is "WARNING: using this option makes the transfer insecure." Fine for one look at a dev box, wrong in a pipeline that downloads artifacts.

A real case: green in the browser, red on the runner

A CI runner started failing curl https://packages.example.internal/… with error 60 the morning after that server's certificate was renewed. Chrome showed a valid padlock, so the team suspected the runner's bundle. curl -v showed the normal CAfile, and openssl s_client showed a single certificate at depth 0 with Verify return code: 21. The renewal had installed cert.pem instead of fullchain.pem; Chrome had been fetching the intermediate by itself. They pointed nginx at fullchain.pem, reloaded, and the runner went green with no client change.

Verify the fix and keep it fixed

curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://packages.example.internal/

200 0 is what you want: ssl_verify_result is 0 only when verification succeeded, and openssl s_client should now list depth 0, 1 and Verify return code: 0 (ok). To keep it from coming back: test with curl, not a browser, after every renewal; bake the corporate CA into base images and runner templates with update-ca-certificates; keep ca-certificates in the image's regular upgrade set; and never commit -k — --cacert with a pinned bundle is the same amount of typing.

How this differs from the other 60s, and from tools with their own store

self-signed certificate without "in chain" means the server's own certificate is its issuer — an internal service whose CA you add as in Fix 2. self-signed certificate in certificate chain is the proxy case with the interception CA visible in the chain. certificate has expired is a date problem on the server, or a wrong clock on the client — check date first. SSL: no alternative certificate subject name matches target host name means the chain is fine and the URL's name is not in the certificate. Tools with their own trust store fail the same way and have their own switch: git (http.sslCAInfo), pip (--cert), Node (NODE_EXTRA_CA_CERTS).

Next time you hit this, walk these checks back in order: curl -v for the store, openssl s_client for the chain, then fix whichever side is missing the link.

Related questions

Why does the browser open the site fine while curl fails with error 60?

Two reasons cover almost every case. Browsers fetch a missing intermediate certificate on their own, so a server that sends only its leaf looks fine in Chrome and broken in curl. And on managed laptops the OS trust store already contains the company's TLS-inspection CA, which a container, CI runner or fresh VM has never seen. openssl s_client shows which one you have: a single certificate at depth 0 is the first, an issuer naming your company is the second.

Can I just add -k and move on?

-k/--insecure skips verification entirely; the manual's own line is "WARNING: using this option makes the transfer insecure." It is fine for one look at a dev box. In a script or pipeline that downloads artifacts, use --cacert with the right bundle instead, or add the CA to the system store once — same amount of typing, and the transfer stays verified.

Which CA file does my curl actually use?

Run curl -v against any HTTPS URL; the OpenSSL backend prints CAfile and CApath just before the TLS handshake. If they are not the distribution bundle, check CURL_CA_BUNDLE, SSL_CERT_FILE and SSL_CERT_DIR in the environment, and remember that curl from conda or Git for Windows ships its own bundle. On curl 8.10.0 and later, --dump-ca-embed prints a bundle compiled into the binary, if there is one.

I ran update-ca-certificates and it still fails. What did I miss?

On Debian and Ubuntu the file in /usr/local/share/ca-certificates/ must end in .crt or it is ignored; the command's output should say "1 added". Then confirm with curl -v that CAfile is /etc/ssl/certs/ca-certificates.crt and not a path from an environment variable, and that the curl you are running is the system one (which curl). If the error is (77) rather than (60), the bundle path itself is wrong.

openssl says "unable to verify the first certificate" but curl says "unable to get local issuer certificate". Are those the same problem?

Usually yes. curl reports the OpenSSL verify error for the certificate it got stuck on; s_client's summary line reports code 21 when the server sent only one certificate and it could not be linked to a trusted root. Both mean the intermediate is missing from what the server sent or from your store — the depth listing above the summary tells you which.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker