DevOps

Fix cURL Error 60 SSL Certificate in Docker Debian

Resolve cURL error 60: SSL certificate problem in Docker Debian Bullseye. Update CA certificates, upgrade base image, or fix trust store for Laravel Guzzle HTTPS APIs securely.

1 answer 1 view

cURL error 60 only for one HTTPS API inside Docker (Debian Bullseye) — works in Postman and locally

I’m experiencing an SSL/TLS verification failure in a Laravel application only when running inside a Docker container (Debian Bullseye), and only for a single external HTTPS API.

Error:

cURL error 60: SSL certificate problem: unable to get local issuer certificate

Environment:

  • Framework: Laravel
  • HTTP client: Guzzle (via Saloon / Laravel HTTP client)
  • PHP: 8.4
  • Docker base image: public.ecr.aws/y5t8n5x1/php:8.4-apache-bullseye
  • OS inside container: Debian Bullseye (11)
  • SSL verification: enabled (no verify=false)

Dockerfile (relevant parts):

dockerfile
FROM public.ecr.aws/y5t8n5x1/php:8.4-apache-bullseye

RUN apt-get update && apt-get install -y --no-install-recommends \
 curl \
 ca-certificates \
 && update-ca-certificates \
 && rm -rf /var/lib/apt/lists/*

What I’ve verified:

  • The API works in Postman
  • The API works on my local machine
  • It fails only inside Docker
  • It fails only for this one API
  • Other HTTPS APIs work fine from the same container

OpenSSL diagnostics (inside the container):
Command:

bash
openssl s_client -connect api.example.com:443 -servername api.example.com

Result:
verify error:num=20:unable to get local issuer certificate

The server presents a valid leaf and intermediate certificate that chains to a newer public root CA not present in Debian Bullseye’s CA bundle. Running update-ca-certificates doesn’t help. Using curl’s external CA bundle (cacert.pem) makes the issue go away; using php:8.4-apache-bookworm also resolves it. I don’t want to disable SSL verification or hardcode CA paths in Laravel/Guzzle.

Questions:

  1. Is this expected when an external service rotates to a newer public root CA that an older OS (Debian Bullseye) doesn’t trust?
  2. From a Laravel + Docker production standpoint, what is the correct, production-safe solution:
  • Upgrade the base image/OS?
  • Pull a newer CA bundle into the container?
  • Ask the server operator to present a different certificate chain (e.g., cross-signed intermediate)?
  1. Is this purely an OS trust-store issue or could Laravel/Guzzle configuration cause it?
  2. If upgrading the base image is not possible, what is a secure, maintainable way to update the container trust store for production?

Yes — when an external API moves to a newer public root CA that’s not present in Debian Bullseye’s trust store you will see cURL error 60: SSL certificate problem inside a container even though Postman or a newer host works. The correct production-safe fixes are to upgrade the base image (preferred) or to update the container trust store in an automated, auditable way (install a vetted CA bundle or add the specific root via /usr/local/share/ca-certificates and run update-ca-certificates); do not disable verification. This is primarily an OS trust-store issue, though PHP/cURL/OpenSSL settings determine which bundle is actually used.


Contents


Diagnosing curl error 60 and SSL certificate problem in Docker (Debian Bullseye)

Short version: OpenSSL (used by libcurl/PHP on Debian) tried to build a chain from the leaf certificate up to a trusted root in the local trust store and failed because the required issuer/root wasn’t present. The OpenSSL error you saw—verify error:num=20:unable to get local issuer certificate—means the client couldn’t find a trusted issuer for some certificate in the chain.

Why this can appear only in the container and only for one API

  • The container runs Debian Bullseye with the Bullseye CA bundle. Bullseye’s package may not include very new public roots that landed later in Bookworm or other OSes.
  • Postman and your local machine may have a newer trust store (or use a different trust store implementation), so they validate the chain successfully.
  • Other APIs work because they present a chain that ends at a root already trusted by Bullseye (or they include a cross-signed intermediate that chains to an older root).

The fact that curl with an external cacert.pem works and that switching to php:8.4-apache-bookworm fixes the problem are the smoking guns: this is a trust-store / CA-bundle age mismatch, not a TLS protocol bug in your code.

Helpful references:


Quick diagnostics to inspect the server chain (commands to run)

Run these from inside the failing container. They’ll show what the server sends and whether a newer bundle fixes verification.

  1. Inspect the chain the server sends:
bash
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts

Look at the certificates printed. The server should normally send the leaf plus any intermediates it relies on. If an intermediate is missing or the chain ends at a root not in the container bundle, verification fails.

  1. Try verification using a known-good bundle (e.g., the Mozilla bundle from curl.se):
bash
# download for quick test (do this only for debugging)
curl -fsSL https://curl.se/ca/cacert.pem -o /tmp/cacert.pem
openssl s_client -connect api.example.com:443 -servername api.example.com -CAfile /tmp/cacert.pem

If that succeeds, the problem is definitely the container’s CA bundle.

  1. Extract and inspect issuer/subject fields:
bash
# save the leaf certificate
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
 | sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' \
 > /tmp/leaf.pem

openssl x509 -in /tmp/leaf.pem -noout -text | sed -n '1,160p'

Check the “Issuer” fields to identify which intermediate or root is required. Then search your container bundle for the issuer fingerprint if you want to confirm presence/absence.

  1. Check which CA file PHP/OpenSSL/cURL are using:
bash
php -r 'print_r(openssl_get_cert_locations());'
php -i | grep -i 'curl.cainfo\|openssl.cafile' -n || true

These commands show where PHP/OpenSSL expect CA files, which helps map which bundle must be updated.


Production-safe fixes: upgrade base image vs update CA bundle vs server-side change

Short recommendation: prefer upgrading the base image to a newer Debian (Bookworm or later) that already contains the updated CA bundle and security fixes. If upgrading isn’t possible immediately, update the container trust store in a secure, automated way and add CI checks.

Option A — Upgrade the base image (recommended)

  • Change your FROM line to an image based on Bookworm or a maintained image that receives ca-certificates updates. Example:
dockerfile
FROM public.ecr.aws/y5t8n5x1/php:8.4-apache-bookworm
# rebuild and run test suite + TLS tests for critical endpoints
  • Pros: gets you up-to-date roots and other security fixes; minimal runtime hacks.
  • Cons: you must test the app against the newer distro (library versions, FHS differences, etc.), but that work is worth the long-term maintenance savings.

Option B — Update the CA bundle inside your Bullseye image (if you can’t upgrade)

  • Two safe patterns:
  1. Add the single missing root certificate (best if only one root is missing).
  • Fetch the root PEM from the CA’s official site, verify its fingerprint in CI, then add it to /usr/local/share/ca-certificates/my-root.crt and run update-ca-certificates.
  1. Install a vetted, up-to-date Mozilla CA bundle (e.g., from https://curl.se/ca/cacert.pem) and ensure PHP/cURL use it. Always verify a SHA256 checksum in CI before baked into image.

Example: add a single root (recommended if you can identify it)

dockerfile
# in Dockerfile
COPY new-root.pem /usr/local/share/ca-certificates/new-root.crt
RUN update-ca-certificates

Or, download + verify during build:

dockerfile
ARG CACERT_URL=https://curl.se/ca/cacert.pem
ARG CACERT_SHA256=put-the-expected-sha256-here
RUN set -eux; \
 apt-get update && apt-get install -y --no-install-recommends ca-certificates curl; \
 curl -fsSL "$CACERT_URL" -o /tmp/cacert.pem; \
 echo "$CACERT_SHA256 /tmp/cacert.pem" | sha256sum -c -; \
 mv /tmp/cacert.pem /etc/ssl/certs/ca-certificates.crt; \
 printf "openssl.cafile=/etc/ssl/certs/ca-certificates.crt\ncurl.cainfo=/etc/ssl/certs/ca-certificates.crt\n" \
 > /etc/php/8.4/cli/conf.d/20-ca.ini

Notes:

  • Verify the SHA256 in CI (don’t hardcode an unchecked download). That preserves supply-chain integrity.
  • Setting PHP’s openssl.cafile/curl.cainfo at the system level is acceptable and avoids per-application code changes.

Option C — Ask the server operator to change their chain

  • The server can present a different chain (e.g., include a cross-signed intermediate that chains to a widely trusted older root). This is a valid compatibility fix, but you don’t control the server; you should still plan client-side fixes. Historically, cross-signing has been used (e.g., Let’s Encrypt) to preserve compatibility, but it’s not guaranteed.

Why you shouldn’t disable verification

  • Turning off verification or using verify=false is an immediate workaround but removes important security guarantees and should never be used in production.

Relevant docs:


Is this an OS trust-store issue or a Laravel/Guzzle configuration problem?

Mostly OS trust-store. In a typical Debian container, libcurl/OpenSSL use the system CA store at /etc/ssl/certs/ca-certificates.crt (and hashed certs in /etc/ssl/certs). If that bundle lacks the new root, OpenSSL can’t complete chain verification and libcurl returns CURLE_SSL_CACERT (60).

That said, app-level config can change behavior:

Practical checks inside the container:

bash
php -r 'print_r(openssl_get_cert_locations());'
php -i | grep -i 'curl.cainfo\|openssl.cafile' -n || true

If those values are empty, PHP/OpenSSL will default to the distro CA bundle—so updating the system bundle fixes PHP too.


Secure, maintainable Dockerfile patterns to update the trust store

Pick one approach depending on how quickly you can change the base image.

  1. Best (short-term + long-term): Upgrade the base image to Bookworm
dockerfile
FROM public.ecr.aws/y5t8n5x1/php:8.4-apache-bookworm
# rebuild, run tests, push
  1. Safe alternative: multi-stage copy of only the trust store from a newer image
dockerfile
# stage A: get certs from a newer image
FROM public.ecr.aws/y5t8n5x1/php:8.4-apache-bookworm AS certs

# stage B: your original image
FROM public.ecr.aws/y5t8n5x1/php:8.4-apache-bullseye
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=certs /usr/share/ca-certificates /usr/share/ca-certificates
RUN update-ca-certificates
  • Pros: minimal change to the runtime image while bringing the newer CA bundle.
  • Cons: mixing files across releases needs testing, but copying only the trust store is low-risk.
  1. If you must add a single root (recommended when you can identify it)
  • Identify the missing root (from openssl inspection), fetch its PEM from the official CA source, verify fingerprint, and add it:
dockerfile
COPY new-root.pem /usr/local/share/ca-certificates/new-root.crt
RUN update-ca-certificates
  1. Always automate verification and tests
  • In CI, pin SHA256 for any downloaded bundle, run build-time verification, and include a post-build smoke test that runs openssl s_client and curl against the problem API with the system CA file to validate success.
  1. CI & maintenance practices
  • Schedule periodic base-image upgrades (monthly or quarterly), or build images on a rolling basis using a reproducible pipeline.
  • Add a small integration test in CI that runs from the built image and confirms TLS validation to the external API(s).

Security notes

  • Never add arbitrary certs without verifying provenance.
  • Prefer trusting a single vetted root PEM if only one root is missing (less surface area).
  • Verify checksums in build pipeline rather than relying solely on HTTPS.

Sources


Conclusion

You’re seeing cURL error 60 because the container’s Debian Bullseye trust store doesn’t include the newer root CA the API’s chain relies on. The safest fix is to move to a base image with an updated CA bundle (Bookworm or later); if that’s not immediately possible, update the container trust store in a repeatable, auditable way (add the vetted root or install an updated Mozilla bundle with checksum verification), and add CI checks that re-verify the endpoint. Don’t disable verification—handle the root(s) in the OS trust store or via a system-wide PHP config so Laravel/Guzzle continue to verify TLS properly.

Authors
Verified by moderation
Moderation
Fix cURL Error 60 SSL Certificate in Docker Debian