DEV Community

DEV-AI
DEV-AI

Posted on

Stop Baking Certificates Into Your Docker Images: A Kubernetes-Native Approach to Custom CA Trust

If you've ever had to rebuild, re-tag, and redeploy a container image just because an internal CA certificate rotated, this article is for you. We'll walk through why baking certificates into images is an anti-pattern, and two production-ready alternatives that decouple certificate lifecycle from application deployment entirely.

The Problem: Certificates Baked Into the Image

A common pattern for Java applications that need to trust an internal/private CA looks like this:

# Package stage
FROM registry.example.com/eclipse-temurin:21.0.7_6-jre-alpine-3.21
WORKDIR /app
COPY ./internal-ca.crt internal-ca.crt
COPY ./*.jar ./app.jar
RUN keytool -noprompt -import -alias internal-ca \
    -keystore "/opt/java/openjdk/lib/security/cacerts" \
    -file internal-ca.crt -storepass "changeit"
ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

It works, and that's exactly the problem — it works until the certificate rotates. At that point:

  • The image must be rebuilt from scratch, even though your application code hasn't changed.
  • Every environment (dev, staging, prod) needs a fresh image tag pushed to the registry.
  • Every Deployment manifest referencing that tag needs updating and rolling out.
  • CI/CD pipelines that have nothing to do with certificate management suddenly become part of the incident response process.

This couples two completely unrelated lifecycles — application releases and certificate rotation — into a single artifact. In regulated environments where CAs rotate on a fixed cadence (90 days, one year, etc.), this becomes a recurring operational headache, not a one-off task. fouts

The Core Idea: Externalize Trust Material

The fix is straightforward in principle: the certificate should never live inside the image. Instead, it should be:

  1. Stored as a Kubernetes-native object (Secret or ConfigMap).
  2. Mounted into the pod at runtime.
  3. Imported into the JVM's truststore by an init container, before the application container starts.

This means your Dockerfile becomes cert-agnostic — it only changes when your application code changes:

# Package stage — no certificate logic at all
FROM registry.example.com/eclipse-temurin:21.0.7_6-jre-alpine-3.21
WORKDIR /app
COPY ./*.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

Certificate rotation now becomes a Kubernetes object update, not an image rebuild.

Approach 1: Init Container + emptyDir Truststore

This is the most portable option and works on any Kubernetes distribution without extra operators.

Step 1 — Store the certificate as a Secret

kubectl create secret generic internal-ca-cert \
  --from-file=ca.crt=./internal-ca.crt \
  -n my-namespace
Enter fullscreen mode Exit fullscreen mode

Rotating the cert later is a single idempotent command:

kubectl create secret generic internal-ca-cert \
  --from-file=ca.crt=./internal-ca-renewed.crt \
  -n my-namespace \
  --dry-run=client -o yaml | kubectl apply -f -
Enter fullscreen mode Exit fullscreen mode

Step 2 — Deployment template with init container

The JDK's shipped cacerts file lives on a read-only image layer, so we copy it into a writable emptyDir volume, then run keytool against that copy inside an init container:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: my-namespace
spec:
  replicas: 2
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      initContainers:
        - name: import-ca-cert
          image: registry.example.com/eclipse-temurin:21.0.7_6-jre-alpine-3.21
          command:
            - sh
            - -c
            - |
              cp /opt/java/openjdk/lib/security/cacerts /truststore/cacerts
              keytool -noprompt -import -alias internal-ca \
                -keystore /truststore/cacerts \
                -file /certs/ca.crt \
                -storepass changeit
          volumeMounts:
            - name: cert-input
              mountPath: /certs
            - name: truststore
              mountPath: /truststore
      containers:
        - name: app
          image: registry.example.com/my-app:1.4.2
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-Djavax.net.ssl.trustStore=/truststore/cacerts -Djavax.net.ssl.trustStorePassword=changeit"
          volumeMounts:
            - name: truststore
              mountPath: /truststore
      volumes:
        - name: cert-input
          secret:
            secretName: internal-ca-cert
        - name: truststore
          emptyDir: {}
Enter fullscreen mode Exit fullscreen mode

Every time the pod restarts (rolling update, node eviction, scale event), the init container re-imports whatever certificate is currently in the Secret — so the truststore is always current without a rebuild.

Closing the loop: triggering restarts on rotation

Since the JVM reads the truststore only at startup, updating the Secret alone won't refresh a running pod. Pair this with a controller like Reloader that watches Secrets/ConfigMaps and triggers a rolling restart automatically:

metadata:
  annotations:
    secret.reloader.stakater.com/reload: "internal-ca-cert"
Enter fullscreen mode Exit fullscreen mode

With this annotation on the Deployment, a kubectl apply on the Secret is the only manual step required for full rotation — everything downstream (restart, re-import, rollout) happens automatically.

Approach 2: Cluster-Wide Trust Distribution with cert-manager + trust-manager

If you're managing certificates for many services across many namespaces, manually keeping Secrets in sync everywhere doesn't scale. trust-manager (a companion project to cert-manager) solves this by continuously reconciling a Bundle custom resource into a ConfigMap replicated across selected namespaces — including native JKS output for JVMs. cert-manager

Step 1 — Define a Bundle resource

apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca-bundle
spec:
  sources:
    - useDefaultCAs: true
    - secret:
        name: internal-ca-cert
        key: ca.crt
  target:
    configMap:
      key: trust-bundle.pem
    additionalFormats:
      jks:
        key: bundle.jks
    namespaceSelector:
      matchLabels:
        trust: "enabled"
Enter fullscreen mode Exit fullscreen mode

Any namespace labeled trust: "enabled" automatically receives (and keeps in sync) a ConfigMap named internal-ca-bundle, containing both a PEM bundle and a ready-to-use bundle.jks file. lestak

Step 2 — Mount it directly, no init container needed

Because the ConfigMap already contains a JKS file, you can skip the keytool step entirely and just point the JVM at it:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: my-namespace
  labels:
    trust: "enabled"
spec:
  replicas: 2
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: registry.example.com/my-app:1.4.2
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-Djavax.net.ssl.trustStore=/certs/bundle.jks -Djavax.net.ssl.trustStorePassword=changeit"
          volumeMounts:
            - name: trust-bundle
              mountPath: /certs
      volumes:
        - name: trust-bundle
          configMap:
            name: internal-ca-bundle
            items:
              - key: bundle.jks
                path: bundle.jks
Enter fullscreen mode Exit fullscreen mode

Kubernetes automatically syncs updated ConfigMap content into the pod's mounted volume within seconds of trust-manager reconciling a rotation — no init container, no manual keytool call, no rebuild. github

Comparing the Two Approaches

Aspect Init container + emptyDir cert-manager + trust-manager
Extra components required None (standard K8s features) cert-manager + trust-manager operators
Rotation trigger Update Secret manually Automatic reconciliation via Bundle CR
Multi-namespace distribution Manual, per-namespace Secret Automatic via namespaceSelector
JVM truststore format Built via keytool at pod start Native JKS output, no keytool needed
Restart on rotation Needs Reloader or similar Needs Reloader or similar
Best for Single service, simple clusters Many services, many namespaces, compliance-driven environments

Key Takeaways

  • Never bake rotating trust material into an image layer — it couples unrelated lifecycles and forces unnecessary rebuilds.
  • Kubernetes Secrets/ConfigMaps plus volume mounts let certificate updates propagate to running pods without touching the image.
  • Init containers are the simplest way to run keytool against a writable truststore copy at pod startup.
  • trust-manager is the better long-term investment if you're distributing the same CA trust to many services across a cluster, since it automates both the reconciliation and the JKS conversion.
  • Whichever approach you choose, pair it with a restart-triggering controller (e.g., Reloader) so rotation is fully hands-off from Secret update to running pod.

With either pattern, your Dockerfile stays clean, your image stops changing for reasons unrelated to your code, and certificate rotation becomes a routine Kubernetes operation instead of a CI/CD fire drill.

Top comments (0)