Files
SRCmail/deploy/k8s
Bernd RodlerandClaude Opus 5 759ab7fe8c feat(ca): EJBCA Community manifests + root ceremony runbook for A-01/A-06
Manifests and a runbook for the internal CA that issues 1-year S/MIME
certificates. Per the agreed split: these are applied by hand, and the
root-key ceremony in section 3 is deliberately NOT automated - the whole
value of an offline root is that its private key never exists on a machine
that runs services or tooling.

Structural recommendation up front (section 0), because it decides whether
promoting to vncmail later is a config change or a re-rooting: name the
root for the ORGANISATION, not the environment. One root, generated once
at prod grade, with per-environment intermediates under it. Promotion is
then "issue a second intermediate from the same root" - a one-hour
ceremony - and the trust anchor already distributed to laptops, phones and
partners does not change. A throwaway "VNC Sandbox Root" instead means
redistributing a new anchor to every device and every external party who
ever verified a signature. That cost is invisible today and expensive
later.

Security shape of the deployment:

- Own namespace (vnc-ca), NOT vncmail. The webmail pod is internet-facing;
  the CA signs certificates. A compromise of the former must not be a
  compromise of the latter.
- Port 8080 (CRL + OCSP) is the ONLY thing the public ingress routes, and
  only two path prefixes. Not the admin web, not the REST API, not the
  public enrolment pages.
- Port 8443 (admin + REST, client-cert authenticated) is never exposed
  through an ingress - cluster-internal or kubectl port-forward only,
  enforced by NetworkPolicy as defence in depth.
- The RA credential the enrolment route uses gets its own EJBCA role
  limited to issue/revoke under one profile. It lives on an
  internet-facing pod, so its blast radius should be "mint an S/MIME cert"
  and not "reconfigure the CA".

Two things the runbook makes you prove rather than assume:

- The NetworkPolicy actually enforces. Applying one on a CNI that does not
  implement it succeeds silently and protects nothing, so section 6 has a
  probe that MUST time out - a 401 means the REST API is exposed
  cluster-wide.
- The CA backup restores. ejbca-db-data holds the intermediate private key
  and, with key recovery on, escrowed user decryption keys; an untested CA
  backup is a belief.

Section 7 surfaces a decision rather than making it silently. S/MIME is
unlike TLS in that losing a private key makes every message ever encrypted
to that user permanently unreadable - re-issuing does not help, the old
mail was encrypted to the old key. So key escrow is on by default here,
which is the defensible choice when mail is a business record, but it
means the CA operator can decrypt user mail. That is worth deciding
consciously and being able to explain, not discovering.

MariaDB rather than the container's embedded H2 deliberately: H2 is not
supported for data you intend to keep, and the database is the one
component that must not need re-platforming on promotion.

Image tag pinned. The env-var contract is the part most likely to have
drifted between EJBCA releases, so the runbook says to verify it against
the tag pulled rather than trusting these values, and gives the log grep
that shows the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:58:04 +02:00
..

VNCmail+ — Admin Deployment Guide (microk8s)

Deploy VNCmail+ (VNC's Bulwark fork) as a container at vncmail.sandbox.vnc.de, alongside the existing bulwark.sandbox.vnc.de. Plain kubectl apply — no GitOps needed.

Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/ telemetry to /app/data, which needs persistent volumes.


1. What you are deploying

# Object File Purpose
1 Namespace vncmail namespace.yaml Isolates the app
2 4× PersistentVolumeClaim pvc.yaml /app/data/{settings,admin,admin-state,telemetry}
3 Secret vncmail-env secret.yaml (you create it) App config (JMAP URL, session secret, branding)
4 Secret ghcr-pull (you create it — command below) Pull the private image from GHCR
5 Deployment vncmail-plus deployment.yaml The app pod
6 Service vncmail-plus service.yaml ClusterIP :80 → pod :3000
7 Ingress vncmail-plus ingress.yaml TLS host vncmail.sandbox.vnc.de

Image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest (built automatically by CI from the dev branch). For anything beyond the sandbox, pin a digest — see §5.


2. Pre-flight — confirm 3 cluster values (2 min)

The manifests use microk8s defaults. Copy the exact values the existing Bulwark uses so VNCmail+ matches your cluster:

# Find bulwark's ingress and read off its class + cert-manager annotations:
kubectl get ingress -A | grep -i bulwark
kubectl get ingress <bulwark-ingress-name> -n <bulwark-ns> -o yaml

# List available storage classes and ingress classes:
kubectl get sc
kubectl get ingressclass
kubectl get clusterissuer      # cert-manager issuers (if used)

Then edit if they differ from the defaults below:

Value Default in manifests File to edit
StorageClass microk8s-hostpath pvc.yaml (all 4)
IngressClass public ingress.yaml
cert-manager issuer letsencrypt-prod ingress.yaml

3. Deploy (copy-paste, in order)

cd deploy/k8s

# a) Namespace
kubectl apply -f namespace.yaml

# b) Image-pull secret — the GHCR package is private.
#    Use a GitHub PAT (classic) with the read:packages scope.
kubectl create secret docker-registry ghcr-pull \
  --namespace vncmail \
  --docker-server=ghcr.io \
  --docker-username=brvncde-dotcom \
  --docker-password='<GITHUB_PAT_read:packages>' \
  --docker-email=br@vnc.biz

# c) App config secret — copy the template, set a real SESSION_SECRET, apply.
cp secret.example.yaml secret.yaml
#    edit secret.yaml:  SESSION_SECRET: "$(openssl rand -base64 32)"
kubectl apply -f secret.yaml

# d) Everything else (PVCs, Deployment, Service, Ingress)
kubectl apply -k .

Alternative to (b): make the GHCR package public (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility), then delete the imagePullSecrets: block from deployment.yaml.


4. Verify

kubectl -n vncmail rollout status deploy/vncmail-plus     # -> successfully rolled out
kubectl -n vncmail get pods,pvc,ingress

# DNS: point vncmail.sandbox.vnc.de at the same ingress IP as bulwark.sandbox.vnc.de.
# cert-manager issues TLS once DNS resolves. Then:
curl -sI https://vncmail.sandbox.vnc.de/api/health         # -> HTTP/2 200

Open https://vncmail.sandbox.vnc.de and log in with a full email address (e.g. bernd.rodler@sandbox.vnc.de) — Stalwart authenticates the full email, not a bare username.


5. Update to a new build

# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`.
kubectl -n vncmail rollout restart deploy/vncmail-plus     # pulls :latest (imagePullPolicy: Always)

# Production: pin a digest instead of :latest so rollouts are deterministic.
kubectl -n vncmail set image deploy/vncmail-plus \
  vncmail-plus=ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>

Rollback: kubectl -n vncmail rollout undo deploy/vncmail-plus


6. Troubleshooting

Symptom Cause / fix
Pod ImagePullBackOff ghcr-pull secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public.
Pod CrashLoopBackOff, logs show EACCES/permission on /app/data Volume not writable by uid 1001. securityContext.fsGroup: 1001 is set in deployment.yaml — keep it; some storage drivers also need it on the PVC.
PVC stuck Pending Wrong storageClassName in pvc.yaml. Set it to one from kubectl get sc.
Ingress has no address / no cert Wrong ingressClassName or cert issuer. Match bulwark's (§2). Check kubectl -n vncmail describe ingress vncmail-plus.
Login shows "Ein Fehler ist aufgetreten" Use the full email (user@sandbox.vnc.de), not a bare username.
Can't reach Stalwart Check JMAP_SERVER_URL in the secret = https://stalwart.sandbox.vnc.de.