Skip to main content

Phase 15 — TLS via cert-manager

Phase 15 establishes a proper internal x509 PKI on the cluster: a self-signed root CA managed by cert-manager, an everyday issuer chained off it, and per-Ingress certificates auto-renewed every 60 days (90-day lifetime, renew-before-30). After this phase every cluster hostname serves HTTPS with a valid (chained) certificate, every HTTP request returns 301 to HTTPS, and the long-standing Phase 7 Harbor pull issue is finally resolved — kubelet trusts the new HTTPS Harbor endpoint and pulls images successfully.

The original phase plan was "Vault + cert-manager + RBAC." We deliberately scoped down to TLS only — same pattern as Phase 11 (scoped to OpenTofu, deferred Crossplane). Doing one thing well > three things shallowly. See "What's deferred" below.


Architecture: the 3-resource bootstrap chain​

ClusterIssuer "selfsigned-bootstrap" ◀── used ONCE to sign the root CA below
│
â–¼
Certificate "minicloud-root-ca" ◀── this IS the root CA cert
(cert-manager namespace) 10 yr lifetime, ECDSA P-256, isCA: true
│ produces secret "minicloud-root-ca" with tls.crt + tls.key
â–¼
ClusterIssuer "minicloud-ca" ◀── everyday issuer; reads the secret above
│
â–¼
Certificate "<workload>-tls" ◀── one per Ingress, in the workload's namespace
(workload namespace) 90 day lifetime, renew at 30d remaining
│ produces secret "<workload>-tls" with tls.crt + tls.key
â–¼
Ingress "<workload>" ◀── references the secret in spec.tls
+ nginx.org/redirect-to-https

This is the canonical cert-manager pattern for internal PKI. Real shops running Vault PKI / Smallstep CA / Active Directory CS use the same shape — just rooted at a different CA.


Decisions​

DecisionChoiceRationale
Issuer strategySelf-signed root CA + chained ClusterIssuerNo public domain → Let's Encrypt HTTP-01 can't verify private IPs; DNS-01 needs a real DNS zone. Self-signed root is the right shape for homelab + portfolio, and demonstrates the same PKI knowledge a corporate CA hierarchy needs.
cert-manager versionv1.20.2 (chart jetstack/cert-manager)Latest stable; supports all the resources we need
Root CA algorithmECDSA P-256, 10-year lifetimeModern algorithm; smaller cert sizes than RSA; 10y is typical for an internal root
Workload cert lifetime90 days, renew at 30d remainingMatches Let's Encrypt cadence; cert-manager handles renewal automatically
Cert distributionPer-namespace Certificate resource → tls.crt/tls.key SecretEach Ingress references its own Secret in spec.tls. cert-manager populates and renews.
HTTP→HTTPS redirectnginx.org/redirect-to-https: "true" annotation on every IngressF5 NGINX Ingress's standard annotation; returns 301
Harbor secret nameharbor-tls-cert (not the chart default)Per the Phase 15 review tip; makes the chart's expose.tls.secret.secretName mapping explicit
Browser trustUser imports ~/minicloud-ca.crt into browser/OS trust store (one-time)Honest portfolio move; the cert chain technically works without it, but green padlocks require trust
Harbor pull-path fixUpdate Phase 10 Ansible k3s-registries role: HTTPS endpoint + distribute the CA cert + reference via tls.ca_fileResolves the Phase 7 known issue (k3s /v2-suffix mirror URL mismatch via HTTP)
VaultDeferred to a future phase (likely alongside Backstage in Phase 18)Vault on a single-control-plane homelab without HA introduces its own SPOF. Currently ~6 admin passwords (small enough for ~/.*-admin files mode 600).
RBACDeferred to when there's a real second userSingle-operator system today; defining roles nobody applies is theatre. RBAC pairs naturally with multi-persona Backstage.

Pre-flight​

# Add jetstack helm repo
helm repo add jetstack https://charts.jetstack.io
helm repo update jetstack
helm search repo jetstack/cert-manager # confirm v1.20+

Install cert-manager​

cert-manager-values.yaml:

crds:
enabled: true

# Modest single-replica install for the small cluster
replicaCount: 1
webhook:
replicaCount: 1
cainjector:
replicaCount: 1

prometheus:
enabled: true
servicemonitor:
enabled: true
labels:
release: kube-prometheus-stack
kubectl create namespace cert-manager
helm install cert-manager jetstack/cert-manager -n cert-manager \
-f cert-manager-values.yaml \
--wait --timeout 5m

# Verify
kubectl get pods -n cert-manager
# 3 pods: cert-manager, cainjector, webhook

Bootstrap the PKI​

Three manifests, applied in order.

01-selfsigned-bootstrap.yaml​

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-bootstrap
spec:
selfSigned: {}

02-root-ca.yaml​

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: minicloud-root-ca
namespace: cert-manager
spec:
isCA: true
commonName: minicloud Internal Root CA
subject:
organizations: [andrelair-platform]
organizationalUnits: [minicloud platform]
duration: 87600h # 10 years
renewBefore: 2160h # renew at 90 days remaining
privateKey:
algorithm: ECDSA
size: 256
secretName: minicloud-root-ca
issuerRef:
name: selfsigned-bootstrap
kind: ClusterIssuer
group: cert-manager.io

03-minicloud-ca.yaml​

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: minicloud-ca
spec:
ca:
secretName: minicloud-root-ca

Apply:

kubectl apply -f 01-selfsigned-bootstrap.yaml
kubectl apply -f 02-root-ca.yaml
kubectl apply -f 03-minicloud-ca.yaml

kubectl wait --for=condition=Ready certificate/minicloud-root-ca -n cert-manager
kubectl get clusterissuers
# selfsigned-bootstrap True
# minicloud-ca True

Export the root CA for browser trust​

kubectl -n cert-manager get secret minicloud-root-ca \
-o jsonpath='{.data.tls\.crt}' | base64 -d > ~/minicloud-ca.crt
chmod 644 ~/minicloud-ca.crt

# Verify the cert
openssl x509 -in ~/minicloud-ca.crt -noout -subject -issuer -dates
openssl x509 -in ~/minicloud-ca.crt -noout -fingerprint -sha256

Per-Ingress migration recipe​

For each Ingress, two manifests:

  1. Certificate in the same namespace as the Ingress:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: <app>-tls
namespace: <app-ns>
spec:
secretName: <app>-tls # âš  Harbor uses harbor-tls-cert (chart-specific)
issuerRef:
name: minicloud-ca
kind: ClusterIssuer
dnsNames: [<host>]
duration: 2160h # 90 days
renewBefore: 720h # renew at 30d remaining
privateKey: { algorithm: ECDSA, size: 256 }
  1. Ingress updates: add nginx.org/redirect-to-https: "true" annotation and spec.tls block:
metadata:
annotations:
nginx.org/redirect-to-https: "true"
spec:
tls:
- hosts: [<host>]
secretName: <app>-tls

For Helm-managed apps (podinfo, Grafana, Harbor, ArgoCD), translate the above into the chart's values syntax. For ArgoCD-managed apps (homer, whoami, platform-demo), commit to the gitops repo and let ArgoCD sync.


Migration order (executed)​

Lowest-risk → highest-risk. Each migration: add Certificate → wait Ready → update Ingress → verify HTTPS + redirect.

OrderAppMethodSpecial note
1whoamigitopsLowest blast radius
2platform-demogitopsVerify CI/CD pipeline still works post-TLS
3podinfohelmHPA still scales after TLS migration
4Homergitops
5GrafanahelmLogin still works (302 redirect to /login)
6HarborhelmThe big one — expose.tls.enabled: true + secret.secretName: harbor-tls-cert
7ArgoCDhelmLast; flip annotations on the Ingress, leave server.insecure: true (TLS terminates at the Ingress)

Verification​

# Each Ingress: HTTPS works, HTTP redirects
for host in homer harbor grafana podinfo whoami platform-demo argocd; do
HTTPS=$(curl -sI --cacert ~/minicloud-ca.crt -m 5 https://$host.10.0.0.200.nip.io/ -o /dev/null -w "%{http_code}")
HTTP=$(curl -sI -m 5 http://$host.10.0.0.200.nip.io/ -o /dev/null -w "%{http_code}")
echo "$host: https=$HTTPS http=$HTTP"
done

# Cert chain inspection
echo | openssl s_client -servername whoami.10.0.0.200.nip.io -connect 10.0.0.200:443 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates

Expected:

  • All HTTPS = 200 (or 302 for Grafana, 405 for podinfo on HEAD — -X GET returns 200)
  • All HTTP = 301 (redirect to HTTPS)
  • Issuer = minicloud Internal Root CA

Harbor pull-path fix (the Phase 7 issue)​

Phase 7's k3s registries.yaml had been pointing at http://harbor.10.0.0.200.nip.io which surfaced a /v2-suffix mismatch in containerd. With Harbor now on HTTPS, the fix is straightforward: switch to HTTPS + add the CA bundle.

Update the Phase 10 k3s-registries Ansible role:

roles/k3s-registries/files/registries.yaml:

mirrors:
"harbor.10.0.0.200.nip.io":
endpoint:
- "https://harbor.10.0.0.200.nip.io"
configs:
"harbor.10.0.0.200.nip.io":
tls:
ca_file: /etc/rancher/k3s/minicloud-ca.crt

Drop the CA cert into roles/k3s-registries/files/minicloud-ca.crt and add a task to install it. Add a handler that restarts k3s (control plane) or k3s-agent (workers) on change:

- name: Restart k3s
ansible.builtin.systemd:
name: "{{ 'k3s' if 'control_plane' in group_names else 'k3s-agent' }}"
state: restarted
become: true

Run the playbook. After restart:

# Push a test image
crane copy --insecure ghcr.io/stefanprodan/podinfo:6.11.2 \
harbor.10.0.0.200.nip.io/library/podinfo:6.11.2

# kubelet pulls it (this used to fail with the /v2-suffix issue)
kubectl create namespace harbor-pull-test
kubectl run pull-test \
--image=harbor.10.0.0.200.nip.io/library/podinfo:6.11.2 \
--image-pull-policy=Always \
--restart=Never \
-n harbor-pull-test
kubectl wait --for=condition=Ready pod/pull-test -n harbor-pull-test --timeout=60s
# → Successfully pulled in ~344ms

The pull-path issue documented across 8 phases (7 → 14) is now closed.


Browser trust (one-time user step)​

Until you import the root CA into your browser/OS trust store, browsers show "Not Secure" warnings. Curl-with-cacert works regardless.

Linux (Ubuntu/Debian):

sudo cp ~/minicloud-ca.crt /usr/local/share/ca-certificates/minicloud-ca.crt
sudo update-ca-certificates

Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import → select minicloud-ca.crt → ✓ trust for websites.

Chrome / Chromium: Settings → Privacy and security → Security → Manage device certificates → Authorities → Import.

macOS: Keychain Access → drag minicloud-ca.crt into "System" → double-click the cert → Trust → "Always Trust."

After import, browsers verify the chain back to your root CA. Hover the padlock — issuer should read "minicloud Internal Root CA."


What's deferred (and why)​

The original Phase 15 was "Vault + cert-manager + RBAC." We shipped only cert-manager.

ComponentStatusFuture home
VaultDeferredPairs naturally with a real consumer (Phase 18 Backstage's secret needs, or external-DB-credentials when we add Postgres in the data layer). On single-control-plane k3s, Vault is its own SPOF.
RBACDeferredPairs with multi-persona Backstage (Phase 18). Defining roles for a single-operator system is theatre.

This is the same scope-reduction pattern as Phase 11 (which dropped Crossplane and shipped only OpenTofu). Honest, documented, with future home identified — same shape every senior platform team uses when managing scope on a busy roadmap.


Done When​

✔ 3 cert-manager pods Running
✔ 2 ClusterIssuers (selfsigned-bootstrap, minicloud-ca) Ready
✔ Root CA Certificate Ready in cert-manager namespace
✔ ~/minicloud-ca.crt exported, SHA256 fingerprint verified
✔ 7 workload Certificates Ready, one per Ingress
✔ All 7 Ingresses serve HTTPS via curl --cacert ~/minicloud-ca.crt
✔ HTTP requests to all 7 hosts return 301
✔ Harbor pull-path verified: kubectl run --image=harbor.10.0.0.200.nip.io/... succeeds
✔ Phase 10 Ansible k3s-registries role updated; --check on all 3 nodes is idempotent
✔ Homer tiles all on https:// URLs

Real-world skills demonstrated​

SkillIndustry context
Self-signed root CA + chained ClusterIssuerSame pattern Vault PKI, Smallstep CA, AD Certificate Services use. The 3-resource bootstrap chain is the textbook cert-manager pattern.
per-Ingress Certificate resources with auto-renewalThe standard cert-manager workflow. Same shape whether the issuer is internal (here) or external (Let's Encrypt, Sectigo, DigiCert).
HTTP→HTTPS redirect at the Ingress layerEdge redirect is more efficient than app-layer redirect. Saves a round trip into the cluster.
Cross-team-conscious migration orderLowest-blast-radius first; critical-path apps last. Same calculus every platform team uses for any cluster-wide change.
Harbor TLS chart configurationThe expose.tls.enabled: true + explicit secret.secretName pattern is the cleanest way to make a chart consume a cert-manager-issued secret.
k3s registries.yaml + CA bundle + handler restartDistribute the CA to each node, point containerd at it, restart k3s — every shop running an internal registry walks this exact path.
Senior scope reductionDeferring Vault + RBAC is the same skill as Phase 11's Crossplane deferral. One thing well > three things shallowly.
Honest documentation of "browser trust required"A self-signed root works perfectly on the wire but needs an out-of-band trust import for browsers. Naming the gap is more credible than pretending it's "production-ready" without it.