The browser trusts https://api.localhost, but the same request made from inside a container fails: Node prints Error: unable to verify the first certificate or UNABLE_TO_VERIFY_LEAF_SIGNATURE, Python raises SSLError: CERTIFICATE_VERIFY_FAILED, and Go returns x509: certificate signed by unknown authority. This page adds the local certificate authority to each container's trust path without rebuilding production images and without turning verification off. It is one step of the local HTTPS and reverse proxy setup.

This failure usually appears the first time a server-side component calls another service by its public hostname: a Next.js server component fetching the API, an OAuth callback verifying a token with a local identity provider, or a worker posting to a webhook receiver behind the proxy. Each runtime reads trust anchors from a different place, which is why a fix that works for one language appears not to work for another.

Diagnostic

Reproduce the failure with the runtime's own HTTP client from inside the running container, then check whether the CA is present at all:

#!/usr/bin/env bash
set -euo pipefail
docker compose exec web node -e "fetch('https://api.localhost/health').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code || e.message))"
docker compose exec web sh -c 'ls /usr/local/share/ca-certificates/ 2>/dev/null; echo "NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS:-unset}"'
docker compose exec web sh -c 'grep -c "BEGIN CERTIFICATE" /etc/ssl/certs/ca-certificates.crt'

Expected bad output:

UNABLE_TO_VERIFY_LEAF_SIGNATURE
NODE_EXTRA_CA_CERTS=unset
146

The certificate directory is empty, the Node variable is unset, and the system bundle contains only the public CAs shipped with the base image. Before continuing, confirm the request is actually reaching the proxy: if api.localhost resolves to the container's own loopback, the error is ECONNREFUSED, not a certificate error, and the network alias described in the parent topic is the fix instead.

Where the TLS Client Looks for Trust A request from inside a container reaches the proxy, which presents an mkcert certificate that the container's store cannot verify. Where the TLS Client Looks for Trust web container fetch api.localhost proxy presents mkcert leaf container store public CAs only verification fails: no anchor
The host trusts the CA; the container image was built without it, so its client rejects the same certificate.

Root cause

A container has its own filesystem, and with it its own copy of the CA bundle — usually /etc/ssl/certs/ca-certificates.crt on Debian and Alpine images, /etc/pki/tls/certs/ca-bundle.crt on RHEL-family images. mkcert -install modified the host's stores, not any image. On top of that, several runtimes ignore the system bundle entirely: Node uses a compiled-in copy of Mozilla's CA list unless NODE_EXTRA_CA_CERTS or --use-openssl-ca is set, Python's requests uses the certifi package's bundle, and Java uses its own cacerts keystore. So the fix has two parts: put the CA certificate into the container, and make sure the runtime actually reads the place you put it.

Resolution

  1. Export the CA location on the host so Compose can mount it. Only the public rootCA.pem is mounted; the key never enters a container.
#!/usr/bin/env bash
set -euo pipefail
export CAROOT="$(mkcert -CAROOT)"
test -f "$CAROOT/rootCA.pem" && echo "CA found at $CAROOT/rootCA.pem"
  1. Add a shared Compose fragment that mounts the CA and sets every common runtime variable. An extension field keeps it to one definition reused by each service.
x-local-ca: &local-ca
  volumes:
    - ${CAROOT:?export CAROOT first}/rootCA.pem:/usr/local/share/ca-certificates/mkcert-root.crt:ro
  environment:
    NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/mkcert-root.crt
    REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
    SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
    CURL_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt

services:
  web:
    <<: *local-ca
    build: ./web
    command: ["sh", "-c", "update-ca-certificates && exec node server.js"]
  worker:
    <<: *local-ca
    build: ./worker
    command: ["sh", "-c", "update-ca-certificates && exec python -m worker"]

update-ca-certificates appends every .crt in /usr/local/share/ca-certificates/ to the system bundle, which Python, Go, curl and OpenSSL-based clients read through the variables above. Node reads the extra file directly. Debian-based images need the ca-certificates package installed; Alpine images need apk add ca-certificates.

  1. For Java services, import the CA into the JVM keystore at container start, because Java ignores both the variables and the system bundle:
#!/usr/bin/env bash
set -euo pipefail
keytool -importcert -noprompt -trustcacerts -alias mkcert-local \
  -file /usr/local/share/ca-certificates/mkcert-root.crt \
  -cacerts -storepass changeit
exec java -jar /app/service.jar

Doing the import at start rather than in the Dockerfile keeps the production image untouched, and the import is idempotent enough for development: if the alias already exists keytool exits non-zero, so wrap it in || true when containers are restarted rather than recreated. Spring Boot, Quarkus and plain HttpClient all read the default cacerts, so one import covers them. Gradle and Maven running inside a devcontainer are also Java processes; if they fetch dependencies through a local HTTPS mirror, they need the same import before the first build.

  1. Recreate the containers so the mount and variables apply, then repeat the diagnostic.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --force-recreate web worker
How Each Runtime Finds Extra CAs Table of runtimes, the trust source they read, and the variable or command that adds the local CA. How Each Runtime Finds Extra CAs Runtime Reads from Add local CA with Node.js built-in list NODE_EXTRA_CA_CERTS Python requests certifi bundle REQUESTS_CA_BUNDLE Go, curl, OpenSSL system bundle update-ca-certificates Java cacerts keystore keytool -importcert
Setting all variables in one shared fragment covers every runtime in the stack at once.

Expected output

$ docker compose exec web node -e "fetch('https://api.localhost/health').then(r=>console.log(r.status))"
200
$ docker compose exec worker python -c "import requests; print(requests.get('https://api.localhost/health').status_code)"
200

Both runtimes complete the TLS handshake against the same mkcert-signed certificate the browser uses, and no code path has verification disabled. The certificate chain the containers now validate is the same shape as production — a leaf signed by a CA in the trust store — so any bug that depends on hostname or SAN mismatch shows up locally rather than after deployment.

Prevention

  1. Ban verification bypasses in code review. Add a grep to CI that fails on the usual bypass flags. A local bypass that works tends to be copied into shared configuration later:
#!/usr/bin/env bash
set -euo pipefail
if git grep -nE 'NODE_TLS_REJECT_UNAUTHORIZED|verify=False|InsecureSkipVerify: *true|rejectUnauthorized: *false' -- ':!docs'; then
  echo "TLS verification bypass found"; exit 1
fi
  1. Keep the CA out of images. Mount it at runtime only. A CA baked into an image travels to every registry and environment that pulls it, including production, where it has no business being trusted.

  2. Check it in the doctor script. Extend the onboarding health-check script to run one in-container HTTPS request per runtime, so a missing CAROOT export is caught with a clear message.

Runtime Mount vs Baked-In CA Comparison of mounting the local CA at runtime against copying it into the image. Runtime Mount vs Baked-In CA Copy CA into image Mount CA at runtime rebuild on every laptop no rebuild needed CA ships to registry CA stays on host prod trusts a dev CA prod image unchanged per-developer images one shared image
The mount keeps production images identical and the CA confined to the laptop that created it.

Platform caveats

WSL2: mkcert -CAROOT inside WSL points at a Linux path that may not be the CA your Windows browser trusts. Export CAROOT to the Windows mkcert directory under /mnt/c/Users/<name>/AppData/Local/mkcert so browser and containers share one CA.

macOS (Docker Desktop): the CA directory is under ~/Library/Application Support/mkcert, which contains a space. Keep ${CAROOT} quoted in scripts; Compose handles the space in volume paths correctly.

Apple Silicon (ARM64): distroless and scratch-based images have no shell and no update-ca-certificates. Mount the CA directly over the bundle path the binary reads, for example /etc/ssl/certs/ca-certificates.crt, using a concatenated file generated on the host.

Rollback

Remove the extension fragment and the command wrappers, then recreate the services. The images were never modified, so nothing else needs to be undone:

#!/usr/bin/env bash
set -euo pipefail
git restore compose.yaml
docker compose up -d --force-recreate

Frequently Asked Questions

Why does setting SSL_CERT_FILE not fix my Node service?

Node does not read SSL_CERT_FILE by default; it uses a CA list compiled into the binary. Set NODE_EXTRA_CA_CERTS to the path of the mkcert root, or start Node with --use-openssl-ca so it reads the system bundle after update-ca-certificates has run.

Is NODE_TLS_REJECT_UNAUTHORIZED=0 acceptable for local development only?

It is the most common source of TLS bugs that reach production, because it disables verification for every connection the process makes and is easy to copy into shared configuration. Trusting the local CA costs one mount and one variable and keeps real verification in place.

How do I trust both a corporate CA and the mkcert CA in the same container?

Mount both certificates into /usr/local/share/ca-certificates/ with distinct file names and run update-ca-certificates once; it appends every .crt it finds. For Node, concatenate the two PEM files on the host into one file and point NODE_EXTRA_CA_CERTS at it, because the variable accepts a single path that may contain several certificates.

Do I need to rerun update-ca-certificates after every restart?

Yes, when the bundle is regenerated at start as shown, because the container filesystem is recreated. The command takes well under a second. Alternatively, mount a pre-built bundle from the host so no command is needed at startup.