Fixing Certificate Errors Inside Containers
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.
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
- Export the CA location on the host so Compose can mount it. Only the public
rootCA.pemis 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"
- 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.
- 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.
- 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
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
- 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
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.
Check it in the doctor script. Extend the onboarding health-check script to run one in-container HTTPS request per runtime, so a missing
CAROOTexport is caught with a clear message.
Platform caveats
WSL2:
mkcert -CAROOTinside WSL points at a Linux path that may not be the CA your Windows browser trusts. ExportCAROOTto the Windows mkcert directory under/mnt/c/Users/<name>/AppData/Local/mkcertso 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.