Add Java Truststore in Docker and Kubernetes Using keytool

Build a custom PKCS12 Java truststore with keytool for Docker and Kubernetes: RUN importcert in the image, set JAVA_TOOL_OPTIONS with trustStoreType=PKCS12, or use an initContainer with a mounted CA Secret and emptyDir truststore.

Published

Updated

Read time 9 min read

Reviewed byDeepak Prasad

Java truststore in Docker and Kubernetes with keytool import banner

Java applications inside Docker images and Kubernetes pods inherit the JDK cacerts bundle. That is fine for public HTTPS APIs, but internal services signed by a private CA trigger PKIX path building failed until you add the CA to a truststore the JVM actually loads.

This guide builds truststore.p12 with keytool -importcert, bakes it into a Docker image with JAVA_TOOL_OPTIONS, and shows a Kubernetes initContainer that imports a CA from a Secret into a volume the application container mounts. The keytool import commands were tested locally on Ubuntu; pulling arbitrary base images may fail in restricted networks — the patterns still apply when you build on a CI host with registry access.

Tested on: Ubuntu 26.04 LTS; OpenJDK 25.0.3; kernel 7.0.0-27-generic.


Why not edit cacerts in the image?

Container images ship a JDK cacerts bundle maintained by the distro or base-image vendor. Editing it inside the image couples every JVM process to your manual imports and breaks the next time the base image refreshes OpenJDK packages.

A dedicated PKCS12 file isolates trust to one service and matches how Spring Boot and Tomcat expect trust material.

Read Use custom Java truststore instead of editing cacerts for the full comparison. For the client error text, see Fix Java PKIX path building failed.


Prerequisites

Gather these on your build host before you touch a Dockerfile or Deployment manifest:

  • keytool on your build host — Install keytool on Ubuntu.
  • A PEM or DER CA certificate (ca.crt) from your internal PKI or lab CA.
  • For Kubernetes examples: a cluster with Secrets support and kubectl access.

This lab reuses the demo CA from other guides in this series. Export the CA as PEM — that file is what Docker and Kubernetes copy into the image or Secret:

bash
mkdir -p ~/truststore-k8s-lab
cd ~/truststore-k8s-lab

keytool -genkeypair -alias demo-ca -keyalg RSA -keysize 2048 -validity 3650 \
  -dname "CN=Demo Lab CA,O=Lab,C=US" -ext "bc=ca:true" \
  -keystore ca.p12 -storetype PKCS12 -storepass changeit

keytool -exportcert -alias demo-ca -keystore ca.p12 -storepass changeit -rfc > ca.pem
head -2 ca.pem
text
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIUXYZ...

The PEM file is the input for every importcert step below — no private key, safe to mount as a Kubernetes Secret.


Build truststore.p12 with keytool (local)

Import the CA PEM into a fresh PKCS12 file. The first import creates the store; later imports add more trustedCertEntry rows under new aliases.

bash
keytool -importcert -alias demo-ca -file ca.pem \
  -keystore truststore.p12 -storetype PKCS12 -storepass changeit -noprompt

List entries — expect only trustedCertEntry rows, never PrivateKeyEntry:

bash
keytool -list -keystore truststore.p12 -storepass changeit
text
Keystore type: PKCS12

Your keystore contains 1 entry

demo-ca, Jul 2, 2026, trustedCertEntry,

This truststore.p12 is the artifact you copy into Docker images or let an initContainer write at pod start. Repeat -importcert with a new -alias for each additional CA. To import into the system bundle on a VM instead of a file, see Import certificate into cacerts on Linux — containers should still prefer a custom file.


Docker pattern: RUN keytool and JAVA_TOOL_OPTIONS

Bake the truststore at image build time so every java process in the container inherits the same CA without a wrapper script. JAVA_TOOL_OPTIONS is read automatically by the JVM launcher.

Dockerfile fragment (OpenJDK 21+ base; adjust tag to your registry mirror):

dockerfile
FROM eclipse-temurin:21-jre

COPY ca.pem /tmp/ca.pem

RUN keytool -importcert -alias demo-ca -file /tmp/ca.pem \
    -keystore /opt/java/truststore.p12 -storetype PKCS12 \
    -storepass changeit -noprompt \
 && rm /tmp/ca.pem

ENV JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/opt/java/truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12"

COPY app.jar /app/app.jar
WORKDIR /app
ENTRYPOINT ["java", "-jar", "app.jar"]
Piece Role
RUN keytool -importcert Creates /opt/java/truststore.p12 at image build time
JAVA_TOOL_OPTIONS Applies truststore flags to every JVM in the container
-noprompt Non-interactive import for CI/CD
NOTE
docker pull and remote base images may fail in air-gapped or restricted lab networks. Build on a host that can reach your registry, or copy a pre-built truststore.p12 with COPY truststore.p12 /opt/java/truststore.p12 and skip the RUN keytool layer.
NOTE
JAVA_TOOL_OPTIONS is commonly used in containers because the JVM launcher picks it up at startup. On modern JDKs, JDK_JAVA_OPTIONS is another launcher-focused option, but JAVA_TOOL_OPTIONS remains widely used for -Djavax.net.ssl.* flags.

Spring Boot outbound HTTPS — prefer SSL bundles for client-side trust on Spring Boot 3.1+:

properties
spring.ssl.bundle.jks.internal.truststore.location=file:/opt/java/truststore.p12
spring.ssl.bundle.jks.internal.truststore.password=changeit
spring.ssl.bundle.jks.internal.truststore.type=PKCS12

Attach that bundle to the specific RestClient, WebClient, or JDBC client that calls your internal API. server.ssl.* configures embedded server TLS only — it does not fix outbound PKIX errors by itself.

After you build the image, confirm the truststore survived the layer:

bash
docker run --rm my-app:local keytool -list -keystore /opt/java/truststore.p12 -storepass changeit
text
Keystore type: PKCS12

Your keystore contains 1 entry

demo-ca, Jul 2, 2026, trustedCertEntry,

If the list is empty or the path is missing, the RUN keytool layer failed silently in CI — check build logs for keytool error.


Kubernetes pattern: initContainer and Secret

When the CA rotates or differs per environment, build the truststore at pod start instead of baking it into the app image. Store the CA PEM in a Secret; an initContainer runs keytool -importcert into a shared volume.

Create the Secret from the PEM you exported earlier:

bash
kubectl create secret generic demo-ca --from-file=ca.pem=ca.pem
kubectl get secret demo-ca -o jsonpath='{.data.ca\.pem}' | base64 -d | head -2
text
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIUXYZ...

The initContainer and app container share an emptyDir volume — the init writes truststore.p12, the app mounts it read-only:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: java-app
spec:
  selector:
    matchLabels:
      app: java-app
  template:
    metadata:
      labels:
        app: java-app
    spec:
      volumes:
        - name: truststore-vol
          emptyDir: {}
        - name: ca-pem
          secret:
            secretName: demo-ca
            defaultMode: 0444
      initContainers:
        - name: build-truststore
          image: eclipse-temurin:21-jre
          command:
            - /bin/sh
            - -c
            - |
              keytool -importcert -alias demo-ca -file /ca/ca.pem \
                -keystore /trust/truststore.p12 -storetype PKCS12 \
                -storepass changeit -noprompt
          volumeMounts:
            - name: truststore-vol
              mountPath: /trust
            - name: ca-pem
              mountPath: /ca
              readOnly: true
      containers:
        - name: app
          image: my-app:local
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-Djavax.net.ssl.trustStore=/trust/truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12"
          volumeMounts:
            - name: truststore-vol
              mountPath: /trust
              readOnly: true

Why emptyDir for the generated PKCS12 file:

  • The initContainer creates a writable keystore without checking a binary Secret into Git.
  • The application container mounts the same volume read-only.
  • You can swap the CA Secret and roll the Deployment without rebuilding the app image.

Use a projected volume when you want to mount multiple existing sources — such as Secret plus ConfigMap — into one directory. For an initContainer that writes a new truststore.p12, emptyDir is the simple pattern.

Production: password from a Kubernetes Secret

The lab YAML hardcodes changeit for clarity. In production, source the truststore password from a Secret in both the initContainer and the app container so keytool -storepass and JAVA_TOOL_OPTIONS stay in sync.

The following is a production-style snippet; merge it into the full Deployment manifest above.

yaml
initContainers:
  - name: build-truststore
    image: eclipse-temurin:21-jre
    env:
      - name: TRUSTSTORE_PASSWORD
        valueFrom:
          secretKeyRef:
            name: java-truststore-password
            key: password
    command:
      - /bin/sh
      - -c
      - |
        keytool -importcert -alias demo-ca -file /ca/ca.pem \
          -keystore /trust/truststore.p12 -storetype PKCS12 \
          -storepass "$TRUSTSTORE_PASSWORD" -noprompt

App container — define TRUSTSTORE_PASSWORD before JAVA_TOOL_OPTIONS so Kubernetes can expand $(TRUSTSTORE_PASSWORD) in the value field:

yaml
env:
  - name: TRUSTSTORE_PASSWORD
    valueFrom:
      secretKeyRef:
        name: java-truststore-password
        key: password
  - name: JAVA_TOOL_OPTIONS
    value: "-Djavax.net.ssl.trustStore=/trust/truststore.p12 -Djavax.net.ssl.trustStorePassword=$(TRUSTSTORE_PASSWORD) -Djavax.net.ssl.trustStoreType=PKCS12"
NOTE
Kubernetes can expand $(TRUSTSTORE_PASSWORD) inside env.value when TRUSTSTORE_PASSWORD is defined earlier in the same env list. Order matters: define the Secret-backed password variable first, then define JAVA_TOOL_OPTIONS. If the reference is misspelled or defined later, Kubernetes leaves it unresolved as a literal string.

When the app runs as a non-root user and cannot read /trust/truststore.p12, set pod-level fsGroup or adjust file permissions from the initContainer:

yaml
spec:
  template:
    spec:
      securityContext:
        fsGroup: 1000

Place securityContext.fsGroup on spec.template.spec, not on an individual container, so both init and app containers share the same volume group ownership.

For server-side HTTPS keystores in Kubernetes, mount the PKCS12 keystore Secret separately — see Spring Boot HTTPS with keytool and Tomcat SSL with keytool.


Verify trust from a pod

Exec into the running pod and list the mounted truststore before you blame application code for PKIX errors:

bash
kubectl exec -it deploy/java-app -- keytool -list -keystore /trust/truststore.p12 -storepass changeit
NOTE
If your runtime image does not include keytool, verify the truststore from the initContainer image, use an ephemeral debug container, or temporarily use a JDK/JRE image with keytool during troubleshooting.
text
Keystore type: PKCS12

Your keystore contains 1 entry

demo-ca, Jul 2, 2026, trustedCertEntry,

Confirm demo-ca appears as trustedCertEntry, then confirm the JVM received the truststore flags:

bash
kubectl exec -it deploy/java-app -- printenv JAVA_TOOL_OPTIONS
text
-Djavax.net.ssl.trustStore=/trust/truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12

Optionally ask the JVM which truststore settings it parsed:

bash
kubectl exec -it deploy/java-app -- java -XshowSettings:properties -version 2>&1 | grep -E 'trustStore|trustStoreType'

You should see /trust/truststore.p12 and PKCS12 in the output. Then hit your internal HTTPS endpoint from the app logs or a debug pod with the same JAVA_TOOL_OPTIONS.

If the app still fails PKIX validation:

  1. Confirm the URL hostname matches the server certificate SAN.
  2. Ensure no second truststore property overrides JAVA_TOOL_OPTIONS.
  3. List the keystore inside the running container — an empty file means the initContainer exited before keytool finished.

Troubleshooting

Problem Fix
Truststore exists but PKIX remains Check printenv JAVA_TOOL_OPTIONS inside the pod and confirm trustStoreType=PKCS12 is present
Public HTTPS breaks after setting custom truststore Your custom truststore replaced cacerts; build a composite truststore if the app needs both public and private CAs
InitContainer works once but CA rotation is not picked up Restart or roll the Deployment; emptyDir is recreated per Pod
App runs as non-root and cannot read truststore Adjust initContainer file permissions, or set pod-level fsGroup when your cluster policy allows it
Secret updated but file did not change in running pod Roll the Pod; the initContainer only runs at Pod startup

Security notes

Truststores hold public CA certificates, but they still define which TLS peers your JVM accepts. Treat updates with the same care as other cluster secrets.

  • Replace changeit with a Secret-managed password in production; pass javax.net.ssl.trustStorePassword through Kubernetes Secrets, not plain Deployment YAML in Git. See the production Secret example above.
  • Restrict who can update the CA Secret — a malicious CA entry lets your app trust an attacker-controlled HTTPS endpoint.
  • Prefer cert-manager or your PKI rotation process over manual kubectl create secret for long-lived clusters.

References


Summary

Containerized Java apps need an explicit truststore when they call HTTPS services behind a private CA. Build truststore.p12 with keytool -importcert, point the JVM at it through JAVA_TOOL_OPTIONS (including trustStoreType=PKCS12), or configure Spring Boot 3.1+ SSL bundles for specific outbound clients. In Kubernetes, let an initContainer import a CA PEM from a Secret into an emptyDir volume your app mounts read-only. I verified the import locally produces a trustedCertEntry for demo-ca. Pair this truststore workflow with server keystores from the Spring Boot and Tomcat guides when both inbound and outbound TLS matter.


Frequently Asked Questions

1. How do I add a custom Java truststore in Docker?

RUN keytool -importcert -alias myca -file ca.pem -keystore /opt/java/truststore.p12 -storetype PKCS12 -storepass changeit -noprompt in the Dockerfile, then set ENV JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/opt/java/truststore.p12 -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=PKCS12" so every java process in the container uses it.

2. How do I mount a Java truststore in Kubernetes?

Store the CA PEM in a Secret, run an initContainer that executes keytool -importcert into truststore.p12 on an emptyDir volume, mount that volume into the app container, and pass javax.net.ssl.trustStore, javax.net.ssl.trustStorePassword, and javax.net.ssl.trustStoreType=PKCS12 through JAVA_TOOL_OPTIONS. For Spring Boot 3.1+, use SSL bundles for specific outbound clients.

3. Should I edit cacerts inside a container image?

No. Editing JDK cacerts bakes a global change into the image and fights package updates. Use a dedicated PKCS12 truststore file and point only workloads that need the private CA at it — see custom truststore vs cacerts.

4. What is JAVA_TOOL_OPTIONS for truststore?

JAVA_TOOL_OPTIONS is picked up automatically by the JVM launcher when the process starts. Setting trustStore, trustStorePassword, and trustStoreType there avoids wrapping java -jar in a shell script. For Spring Boot outbound HTTPS, prefer SSL bundles on supported clients; server.ssl.* configures embedded server TLS, not general client trust.

5. Can I use a ConfigMap instead of a Secret for the truststore?

Technically yes if the truststore contains only public CA certificates, but many teams still use a Secret because the truststore controls which TLS peers the app trusts. Use ConfigMap only when your security policy allows it, and restrict RBAC either way.

6. Why does my Kubernetes Java pod still show PKIX path building failed?

The running container is still using default cacerts because JAVA_TOOL_OPTIONS is unset, trustStoreType is missing, the mount path is wrong, or the initContainer wrote an empty file. Exec into the pod, run printenv JAVA_TOOL_OPTIONS, and keytool -list -keystore /trust/truststore.p12 to confirm trustedCertEntry rows.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …