Fix curl error 60: install the full certificate chain in Nginx
Direct Answer: curl error 60 ("unable to get local issuer certificate") happens when the server sends only the leaf certificate and no intermediate CA. Browsers hide this by fetching the missing intermediate via AIA, but curl and OpenSSL fail. Fix it by setting Nginx's ssl_certificate directive to a concatenated file: leaf first, then intermediates.
curl error 60 with "unable to get local issuer certificate" means Nginx served only the leaf cert. Point ssl_certificate at the concatenated chain file.
A 2 a.m. pager alert: your deploy pipeline's smoke test curls https://api.example.dev/health and dies with curl: (60) SSL certificate problem: unable to get local issuer certificate. You open the same URL in Chrome. Green padlock. The mobile app works. Only curl, Python requests, Go, and every container in your fleet are broken.
This is the most common TLS misconfiguration in production, and it is invisible in browsers by design.
Why does curl fail when browsers succeed?
The TLS handshake requires the client to build a chain: leaf certificate โ intermediate CA โ root CA. The root lives in the client's trust store. The intermediate usually does not.
Browsers implement AIA chasing. The leaf certificate contains an Authority Information Access extension with a URL like http://crt.sectigo.com/SectigoRSADomainValidationSecureServerCA.crt. Chrome, Firefox, and Safari fetch that URL mid-handshake and patch the gap.
curl (backed by OpenSSL) does not chase AIA. Neither does Python's ssl module, Go's crypto/tls, Java before recent versions, or Node's default agent. If the server does not send the intermediate, they fail hard.
Your Nginx config almost certainly looks like this:
server {
listen 443 ssl;
server_name api.example.dev;
ssl_certificate /etc/nginx/ssl/cert.pem; # leaf only โ WRONG
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
}
cert.pem contains one certificate. That is the bug.
How does the chain actually get built at the record level?
Walk the handshake:
Client sends ClientHello with SNI api.example.dev.
Server replies ServerHello plus a Certificate message containing every PEM block in the file referenced by ssl_certificate, in file order.
Client parses the first block as the leaf, then walks upward: for each cert, it looks for a cert in the message whose Subject matches the current cert's Issuer.
When the chain reaches a self-signed root, the client checks that root against its local trust store (/etc/ssl/certs/ca-certificates.crt on Debian, the System keychain on macOS).
If step 3 dead-ends โ no cert in the message issued the leaf โ OpenSSL raises X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, surfaced by curl as error 60.
Two details that trip people up:
About the author
Admin SarangAI ยท AI Tools Editor
Part of the SarangAI editorial team covering AI tools, product releases, emerging technology trends, and practical web utilities for developers and creators.
Order matters. The leaf must be first. Put an intermediate first and OpenSSL treats the intermediate as the leaf, then fails to find its issuer.
The root is optional. You may append it, but most clients ignore it. Never rely on the root being sent.
Verify what your server currently sends:
$ openssl s_client -connect api.example.dev:443 -servername api.example.dev -showcerts </dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
1
$ curl -vI https://api.example.dev/health
* SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
One BEGIN CERTIFICATE. That is the smoking gun. A correct chain prints 2 or 3.
Failure case study: the CI pipeline that broke after a renewal
A payments team renews certificates every 90 days. On 2024-03-11 they ran certbot, which wrote cert.pem, chain.pem, fullchain.pem, and privkey.pem into /etc/letsencrypt/live/api.example.dev/. Their Ansible template had pointed at cert.pem since 2021. Browsers passed. Production traffic passed. Nobody noticed.
Then the mobile team bumped their Go client from Go 1.19 to Go 1.22. Go 1.22 tightened chain validation and stopped tolerating missing intermediates in some code paths. Every iOS build started failing the /v1/charges call with x509: certificate signed by unknown authority. Forty minutes later the same error appeared in a Datadog synthetic check.
Root cause: ssl_certificate /etc/letsencrypt/live/api.example.dev/cert.pem. It had never been correct. It was just never tested by a strict client.
Resolution took four minutes: change the directive to fullchain.pem, run nginx -t, reload. The synthetic check went green on the next probe. The Go client worked immediately.
The lesson is that browser-green is not a test. Browser-green is a lie that AIA tells you.
Fix it: numbered remediation walkthrough
Confirm the failure. Run curl -vI https://api.example.dev/health and look for unable to get local issuer certificate (20). Expected result: you see the exact OpenSSL error code, not a generic timeout.
Inspect what the server sends. Run openssl s_client -connect api.example.dev:443 -servername api.example.dev -showcerts </dev/null | grep -c "BEGIN CERTIFICATE". Expected result: a number below 2 confirms a missing intermediate.
Locate the intermediate. In your CA's ZIP, open chain.pem or ca_bundle.crt. For Let's Encrypt, it is /etc/letsencrypt/live/api.example.dev/chain.pem. Expected result: a PEM file with one or two BEGIN CERTIFICATE blocks.
Build the concatenated file. Run cat cert.pem chain.pem > fullchain.pem. Expected result: grep -c "BEGIN CERTIFICATE" fullchain.pem returns the same number as step 3 plus one.
Point Nginx at the chain. Edit the server block: ssl_certificate /etc/nginx/ssl/fullchain.pem;. Expected result: the directive no longer references a leaf-only file.
Validate and reload. Run nginx -t && systemctl reload nginx. Expected result: syntax is ok and test is successful.
Re-verify from the outside. Run openssl s_client -connect api.example.dev:443 -servername api.example.dev -showcerts </dev/null | grep -c "BEGIN CERTIFICATE" again. Expected result: 2 or 3.
Confirm curl is clean. Run curl -sSf https://api.example.dev/health. Expected result: HTTP 200 with no (60) line. Use SarangAI's SSL chain checker before closing the ticket to confirm the chain from an independent vantage point.
Common mistakes that keep error 60 alive
Pointing at cert.pem instead of fullchain.pem. The filenames look interchangeable. They are not. cert.pem is leaf-only; fullchain.pem is leaf plus intermediates.
Reversing the order.cat chain.pem cert.pem > fullchain.pem puts the intermediate first, which produces error 20 or a silent validation failure. Always leaf first.
Serving a stale chain after renewal. Let's Encrypt rotates intermediates (R3 โ R10 โ R11 over 2021โ2024). Reloading Nginx is not enough if the config points at a copied file that was never refreshed.
Fixing only one vhost. A shared ssl_certificate include applied to the wrong server_name leaves a second hostname broken. Check every vhost with the free DNS propagation checker on SarangAI after a reload.
Trusting the browser test. "It works in Chrome" is not evidence. Always verify with openssl s_client or curl before declaring the incident closed.
Which certificate file should Nginx serve?
File
Contents
curl result
Browser result
cert.pem (leaf only)
1 cert
error 60
OK (AIA chase)
chain.pem (intermediates only)
1โ2 certs, no leaf
error 18 or 60
Handshake failure
fullchain.pem (leaf + intermediates)
2โ3 certs, leaf first
OK
OK
fullchain.pem + root appended
3โ4 certs
OK
OK (root ignored)
The only portable choice is fullchain.pem.
How do I automate chain validation in CI?
Stop relying on humans to run openssl s_client. Add a strict check to your deploy pipeline so the next renewal cannot regress silently.
import socket, ssl, sys
HOST, PORT = "api.example.dev", 443
ctx = ssl.create_default_context() # uses system trust store, no AIA chasing
try:
with socket.create_connection((HOST, PORT), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=HOST) as tls:
chain = tls.get_verified_chain() if hasattr(tls, "get_verified_chain") else []
print(f"OK: {HOST}:{PORT} verified, {len(chain)} certs in chain")
except ssl.SSLCertVerificationError as e:
print(f"FAIL: {e.verify_message}")
sys.exit(1)
Run this after every deploy. A green run means the server sent a complete chain that the system trust store accepted without AIA. A red run catches the regression before your customers do.
For a one-off manual check outside CI, use the same openssl s_client command or the free SSL chain checker to inspect the chain depth per hostname.
Key Takeaways
curl error 60 means the server sent 1 certificate; a correct chain sends 2 or 3.
Nginx ssl_certificate must point at fullchain.pem (leaf first), never cert.pem.
Browsers hide this bug via AIA chasing; OpenSSL, Go, and Python do not chase.
Verify with openssl s_client -showcerts and count BEGIN CERTIFICATE blocks.
Add a Python ssl.create_default_context() check to CI to catch renewals that regress.
Frequently Asked Questions
Why does curl fail with error 60 when Chrome loads the same site fine?
Chrome and Safari use AIA chasing to download the missing intermediate certificate at handshake time; curl and OpenSSL do not, so they fail with error 60 unless the server sends the full chain.
Which file should nginx ssl_certificate point to?
It must point to the concatenated fullchain.pem (leaf certificate first, then each intermediate), never to cert.pem or the leaf-only file that most CAs ship in the ZIP download.
How do I verify the chain a server actually sends?
Run openssl s_client -connect example.com:443 -servername example.com -showcerts and count the BEGIN CERTIFICATE blocks; a correct chain returns two or three, a broken one returns exactly one.
Can I fix curl error 60 on the client instead of the server?
Yes, with --cacert pointing at the intermediate's PEM, but that only patches one machine; the server-side chain fix repairs every client, CI runner, and container at once.
Does certificate order inside fullchain.pem matter?
Yes. The leaf must come first, then intermediates ordered from the one that signed the leaf down to the one closest to the root; reversing the order produces error 20 "unable to get local issuer certificate" failures again.