Kubernetes¶
Before upgrading an existing PVC, follow the explicit
offline layout migration. Mount the writable
store-container parent, with PKI state in its store/ child and server-control
state outside that child. The entrypoint refuses an unmigrated legacy root.
On Kubernetes the PKI server runs as a single-replica StatefulSet with per-instance
PersistentVolumeClaims, and the nginx edge runs as a horizontally scalable
Deployment. Configuration comes from ConfigMaps, secrets from Secrets, and the
health probes exec curl over mutual-TLS. This page walks the reference manifests
under deploy/k8s/.
Why a StatefulSet for the server¶
The store is a single-writer, durable backend that takes an exclusive advisory lock on open. Two pods sharing one store volume would contend and risk corruption. The server therefore runs as a StatefulSet with:
- A single replica per realm store. Do not scale the StatefulSet above one replica against the same volumes.
- Per-instance PersistentVolumeClaims for the store, keyring, artifacts, and
audit, provisioned from
volumeClaimTemplates. - A headless Service, so the edge reaches the pod by its stable DNS name.
The nginx edge holds no state, so it is a Deployment (the reference uses two replicas) fronted by a Service.
Namespace, configuration, and secrets¶
The first manifest defines the namespace and the read-only configuration and secret objects.
- A ConfigMap carries the server configuration as
server-config.json. - A ConfigMap carries the certificate-profile documents, one key per file;
the keys become file names under
/etc/zeroecho/profiles. - A Secret carries the four passwords, projected into the server with
envFrom. - A Secret carries the PKCS#12 TLS material, projected at
/etc/zeroecho/tls. - A Secret carries the health-probe client identity, projected read-only at
/etc/zeroecho/health.
Create the password and TLS Secrets from literals and files rather than committing values:
kubectl -n zeroecho-pki create secret generic pki-server-secrets \
--from-literal=ZEROECHO_KEYRING_PASSWORD=... \
--from-literal=ZEROECHO_TLS_KEYSTORE_PASSWORD=... \
--from-literal=ZEROECHO_PROXY_TRUSTSTORE_PASSWORD=... \
--from-literal=ZEROECHO_ADMIN_TRUSTSTORE_PASSWORD=...
kubectl -n zeroecho-pki create secret generic pki-server-tls \
--from-file=server-identity.p12 \
--from-file=proxy-transport-trust.p12 \
--from-file=administrators-trust.p12
The configuration ConfigMap uses absolute in-container paths throughout — the TLS
stores at /etc/zeroecho/tls/..., the store, keyring, artifacts, audit, and
control log under /var/lib/zeroecho/... — so no working-directory-relative
resolution is involved.
The server StatefulSet¶
The StatefulSet pins the runtime identity, mounts configuration and state, tightens the container security context, and defines exec probes.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: pki-server
namespace: zeroecho-pki
spec:
serviceName: pki-server
replicas: 1
selector:
matchLabels: {app: pki-server}
template:
metadata:
labels: {app: pki-server}
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001 # makes the mounted PVCs writable by the runtime user
fsGroupChangePolicy: OnRootMismatch
containers:
- name: pki-server
image: zeroecho/pki-server:latest
ports:
- {name: admin-mtls, containerPort: 8443}
envFrom:
- secretRef: {name: pki-server-secrets}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: server-config, mountPath: /etc/zeroecho/server-config.json, subPath: server-config.json, readOnly: true}
- {name: profiles, mountPath: /etc/zeroecho/profiles, readOnly: true}
- {name: tls, mountPath: /etc/zeroecho/tls, readOnly: true}
- {name: health, mountPath: /etc/zeroecho/health, readOnly: true}
- {name: store, mountPath: /var/lib/zeroecho/store-container}
- {name: keyring, mountPath: /var/lib/zeroecho/keyring}
- {name: artifacts, mountPath: /var/lib/zeroecho/artifacts}
- {name: audit, mountPath: /var/lib/zeroecho/audit}
- {name: tmp, mountPath: /tmp}
A few points worth noting:
readOnlyRootFilesystem: truewith anemptyDirmounted at/tmpkeeps the container image immutable at run time; only the declared volumes are writable.fsGroup: 10001makes the claims group-writable by the non-root runtime user, which removes the manualchownstep that bind mounts require.- The health Secret is projected with a restrictive default mode, since it contains the probe's private key.
The persistent claims are provisioned per instance. Keep the keyring on a separate claim from the store, and use POSIX filesystem StorageClasses:
volumeClaimTemplates:
- metadata: {name: store}
spec:
accessModes: ["ReadWriteOnce"]
resources: {requests: {storage: 20Gi}}
- metadata: {name: keyring}
spec:
accessModes: ["ReadWriteOnce"]
resources: {requests: {storage: 1Gi}}
- metadata: {name: artifacts}
spec:
accessModes: ["ReadWriteOnce"]
resources: {requests: {storage: 5Gi}}
- metadata: {name: audit}
spec:
accessModes: ["ReadWriteOnce"]
resources: {requests: {storage: 10Gi}}
Readiness and liveness probes¶
The admin listener requires a client certificate even for the health routes, so an
HTTP-only probe cannot complete the handshake. Both probes therefore exec curl
with the health-probe client identity mounted at /etc/zeroecho/health:
readinessProbe:
exec:
command:
- curl
- -fsS
- --max-time
- "4"
- --cert
- /etc/zeroecho/health/health-client.pem
- --key
- /etc/zeroecho/health/health-client-key.pem
- --cacert
- /etc/zeroecho/health/health-ca.pem
- https://127.0.0.1:8443/health/ready
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 6
livenessProbe:
exec:
command: [ ... same identity ... , "https://127.0.0.1:8443/health/live"]
initialDelaySeconds: 40
periodSeconds: 20
failureThreshold: 3
Readiness targets /health/ready, which returns 200 only when the realm is ready
to serve; liveness targets /health/live, which confirms the process is up. See
Operations for the semantics.
Services and the edge¶
The server's Service is headless (clusterIP: None) and exposes the admin port to
the cluster's internal DNS only. The nginx Deployment reaches it by its fully
qualified service name, set through PKI_UPSTREAM:
env:
- {name: NGINX_SERVER_NAME, value: "pki.example.com"}
- {name: NGINX_HTTPS_PORT, value: "8443"}
- {name: PKI_UPSTREAM, value: "pki-server.zeroecho-pki.svc.cluster.local:8443"}
- {name: PKI_PROXY_SSL_NAME, value: "zeroecho-admin"}
The edge Deployment projects its three TLS material sets from Secrets and probes
its own plaintext /nginx-health loopback endpoint. Its Service is a
LoadBalancer in the reference manifest; if you place it behind an Ingress, that
Ingress must pass browser mutual-TLS through to nginx rather than terminating
it, because nginx is where the administrator certificate is verified.
The edge must terminate browser mutual-TLS
Do not offload client-certificate verification to an Ingress controller unless it forwards the verified certificate in the exact RFC 9440 form the server expects. The reference topology relies on nginx performing both the termination and the RFC 9440 forwarding. See Security hardening.
Applying the manifests¶
kubectl apply -f deploy/k8s/00-namespace-config.yaml
kubectl apply -f deploy/k8s/10-pki-server.yaml
kubectl apply -f deploy/k8s/20-nginx.yaml
Replace the placeholder Secrets and the example configuration and profile values with your realm's own before applying. See Configuration for the field-by-field contract and Operations for backup, upgrade, and recovery procedures.