Philipp Lehmann @PhilippTheServer

Trusting an Internal and a Public Certificate Authority in One Process

Adding trust for one CA by replacing the trust store breaks every public connection

The problem

A service needs to trust an internally issued certificate — an internal reverse proxy, an internal API, anything signed by a certificate authority you run yourself rather than one in the public Mozilla/CA trust programme. The fastest way to make that work is to point the process straight at the internal CA’s certificate:

# Broken. Do not copy this.
export SSL_CERT_FILE=/etc/internal-ca/ca.pem
curl https://internal-service.internal:8443/

The internal call now succeeds. The same process’s calls to anything with a publicly signed certificate now fail:

curl https://example.com
# curl: (60) SSL certificate problem: unable to get local issuer certificate

SSL_CERT_FILE does not add a trusted issuer, it replaces the entire trust store for whatever reads that variable. The process that used to trust every public CA now trusts exactly one, and that one is not any of the public CAs. This is easy to miss because the person making the change is testing the internal call, which now works — the regression shows up somewhere else, later, in a call to an external identity provider, a package registry, or a webhook target, made by a process nobody thought was affected because nobody touched its code, only its environment.

It gets worse in mixed environments, because different tools read different variables independently: SSL_CERT_FILE and SSL_CERT_DIR affect OpenSSL directly; Python’s requests uses its own bundled certifi package unless REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE is set; the JVM has its own cacerts keystore entirely. “Just set the CA env var” can fix curl and quietly leave a Python sidecar in the same container still trusting nothing but the system default — or the reverse.

Working through it

Distinguishing “replace” from “append”

The tools that manage a Linux distribution’s trust store, update-ca-certificates on Debian/Ubuntu or update-ca-trust on RHEL-family systems, do not replace anything. They read every certificate under a designated local directory, combine it with the distribution’s shipped public CA bundle, and write out one merged file. That merged file is what should end up as the trust root for everything in the process — the internal CA becomes one more trusted issuer alongside the public ones, not a replacement for them.

SSL_CERT_FILE pointed at a single file has no concept of “combine”. It trusts exactly the certificates in that file. The fix is not a different environment variable; it is to make the file it points to be the merged bundle, not the internal CA alone.

Getting the OS layer right first

In a container image, add the internal CA to the standard location and let the distribution’s own tool do the merge:

COPY internal-ca.pem /usr/local/share/ca-certificates/internal-ca.crt
RUN update-ca-certificates

After this, /etc/ssl/certs/ca-certificates.crt contains the public CAs and the internal one. Anything that reads the system default — which is most C-linked tooling, curl included, when no override is set — now trusts both chains with no environment variable needed at all. The correct fix in the common case is to not set SSL_CERT_FILE in the first place and let the OS-level merge do the work.

Handling runtimes that ship their own bundle

Some runtimes deliberately do not use the OS trust store, certifi in the Python ecosystem being the common one, precisely so that a Python install’s TLS behaviour is not at the mercy of whatever the host OS bundle happens to contain. That default is sound, and it still needs the same merge treatment for the internal CA: concatenate the runtime’s bundle with the internal CA rather than replacing one with the other.

cat "$(python -c 'import certifi; print(certifi.where())')" internal-ca.pem > /etc/ssl/certs/combined-ca.pem
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/combined-ca.pem

Now requests trusts both the public CAs it shipped with and the internal one, without touching the OS trust store that curl and everything else in the container reads.

Verifying both directions, not just the one you changed

The mistake that causes this whole class of bug is testing only the connection you were trying to fix. Any change to trust configuration needs a test against a publicly signed endpoint as well, every time, specifically because that is the connection nobody thinks to check.

The solution

A complete, runnable demonstration: a Dockerfile that generates an internal CA and an internal HTTPS service inside the image, installs the CA the correct way, and proves that both the internal service and a real public endpoint are reachable from the same container.

# Dockerfile
FROM debian:bookworm-slim

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

# --- Simulate an internally issued CA and a service certificate signed by it ---
RUN mkdir -p /internal-ca && cd /internal-ca && \
    openssl req -x509 -newkey rsa:2048 -days 3650 -nodes \
      -keyout ca-key.pem -out ca.pem -subj "/CN=Example Internal CA" && \
    openssl req -newkey rsa:2048 -nodes \
      -keyout server-key.pem -out server.csr \
      -subj "/CN=internal-service.internal" && \
    openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem \
      -CAcreateserial -days 825 -out server.pem \
      -extfile <(printf "subjectAltName=DNS:internal-service.internal")

# --- Install the internal CA correctly: append, do not replace ---
RUN cp /internal-ca/ca.pem /usr/local/share/ca-certificates/internal-ca.crt && \
    update-ca-certificates

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
#!/usr/bin/env bash
# entrypoint.sh — start a local HTTPS server on the internal CA's certificate,
# then prove both trust chains work from the same process.
set -euo pipefail

openssl s_server -quiet -www \
  -cert /internal-ca/server.pem -key /internal-ca/server-key.pem \
  -accept 8443 &
sleep 1

echo "--- internal service, signed by the internal CA ---"
curl -sS --resolve internal-service.internal:8443:127.0.0.1 \
  https://internal-service.internal:8443/ -o /dev/null -w "HTTP %{http_code}\n"

echo "--- public endpoint, signed by a public CA ---"
curl -sS https://example.com -o /dev/null -w "HTTP %{http_code}\n"

wait
docker build -t two-ca-demo .
docker run --rm two-ca-demo
--- internal service, signed by the internal CA ---
HTTP 200
--- public endpoint, signed by a public CA ---
HTTP 200

Delete the RUN update-ca-certificates line, or replace it with ENV SSL_CERT_FILE=/internal-ca/ca.pem, rebuild, and rerun: the internal call still returns HTTP 200, and the public one fails with a certificate verification error. That is the regression this article is about, reproduced deliberately so you can see exactly what breaks and why the fix above avoids it.

Conclusion

A trust store override is process-wide, not endpoint-specific. SSL_CERT_FILE and its equivalents do not mean “also trust this”; they mean “trust only this”. Every connection the process makes is affected, including ones you were not thinking about when you set the variable.

Prefer the OS-level merge over an application-level override whenever you can. update-ca-certificates (or the RHEL-family equivalent) exists specifically to combine a local CA with the distribution’s public set, and letting it do that means every tool in the container that reads the system default benefits without individual configuration.

A runtime with its own bundle needs the same treatment, deliberately. certifi and similar bundled trust stores are a reasonable design choice, not a bug to work around — but they still need the internal CA concatenated in, not swapped in, or you have solved the problem for curl and silently broken it for the runtime sitting next to it.