Wiki.js was running on a LoadBalancer IP with no TLS. This post covers migrating to HTTPS using a Let’s Encrypt wildcard certificate managed by OPNsense, with automatic synchronization to Kubernetes.
The Problem
The wiki was accessible at http://192.168.2.204 with:
- No TLS encryption
- Direct LoadBalancer service exposure
- No ingress controller
The goal: HTTPS with a publicly trusted certificate, no browser warnings.
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ OPNsense Firewall │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ACME Client │ │
│ │ - Let's Encrypt account │ │
│ │ - Cloudflare DNS-01 validation │ │
│ │ - Wildcard cert: *.minoko.life │ │
│ │ - Auto-renewal at 60 days │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
OPNsense API (daily sync)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ ┌─────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ letsencrypt-sync │ │ ingress-nginx namespace │ │
│ │ CronJob (5 AM) │───▶│ letsencrypt-wildcard secret │ │
│ │ - Fetch from API │ └─────────────────────────────────────────┘ │
│ │ - Build cert chain │ ┌─────────────────────────────────────────┐ │
│ │ - Update secrets │───▶│ wikijs namespace │ │
│ └─────────────────────┘ │ letsencrypt-wildcard secret │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ingress-nginx-controller │ │
│ │ LoadBalancer: 192.168.2.224 │ │
│ │ │ │
│ │ Ingress: wiki.minoko.life ──▶ wikijs:80 │ │
│ │ TLS: letsencrypt-wildcard secret │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Prerequisites
- OPNsense with ACME plugin configured for Let’s Encrypt
- Cloudflare (or other DNS provider) for DNS-01 validation
- Existing wildcard certificate for
*.minoko.life
Step 1: Install ingress-nginx Controller
Create the ingress-nginx infrastructure:
infrastructure/ingress-nginx/
├── ingress-nginx-values.yaml
├── setup-ingress-nginx.sh
└── README.md
Helm values:
# ingress-nginx-values.yaml
controller:
service:
type: LoadBalancer
annotations:
metallb.universe.tf/loadBalancerIPs: "192.168.2.224"
externalTrafficPolicy: Local
podAnnotations:
linkerd.io/inject: enabled
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
metrics:
enabled: true
serviceMonitor:
enabled: true
namespace: monitoring
config:
use-forwarded-headers: "true"
compute-full-forwarded-for: "true"
admissionWebhooks:
enabled: true
# Disable Linkerd for webhook jobs (sidecars prevent job completion)
patch:
podAnnotations:
linkerd.io/inject: disabled
createSecretJob:
podAnnotations:
linkerd.io/inject: disabled
patchWebhookJob:
podAnnotations:
linkerd.io/inject: disabled
defaultBackend:
enabled: false
The Linkerd annotation on admission webhook jobs is critical. Without disabling injection, the webhook jobs never complete because the Linkerd sidecar keeps running after the main container exits.
Installation:
kubectl create namespace ingress-nginx --dry-run=client -o yaml | kubectl apply -f -
kubectl annotate namespace ingress-nginx linkerd.io/inject=enabled --overwrite
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--values ingress-nginx-values.yaml \
--wait
Verify:
kubectl get svc -n ingress-nginx
kubectl get ingressclass
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 10.101.137.141 192.168.2.224 80:31369/TCP,443:31236/TCP
NAME CONTROLLER PARAMETERS
nginx k8s.io/ingress-nginx <none>
Step 2: Export Certificate from OPNsense
OPNsense manages the Let’s Encrypt wildcard certificate via its ACME plugin. The certificate needs to be exported and formatted for Kubernetes.
Query the OPNsense API to find the certificate:
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://firewall.minoko.life:8443/api/trust/cert/search" | \
jq '.rows[] | select(.descr | test("minoko")) | {descr, commonname}'
{
"descr": "minoko.life (ACME Client)",
"commonname": "minoko.life"
}
The certificate includes the wildcard SAN (*.minoko.life). Export it:
# Server certificate
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://firewall.minoko.life:8443/api/trust/cert/search" | \
jq -r '.rows[] | select(.descr | test("minoko")) | .crt_payload' > /tmp/tls.crt
# Private key
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://firewall.minoko.life:8443/api/trust/cert/search" | \
jq -r '.rows[] | select(.descr | test("minoko")) | .prv_payload' > /tmp/tls.key
# Intermediate CA (Let's Encrypt R12)
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://firewall.minoko.life:8443/api/trust/ca/search" | \
jq -r '.rows[] | select(.descr | test("R12")) | .crt_payload' > /tmp/intermediate.crt
Build the full certificate chain:
cat /tmp/tls.crt /tmp/intermediate.crt > /tmp/tls-fullchain.crt
Without the intermediate certificate, browsers cannot verify the chain and display warnings.
Step 3: Create Kubernetes Secret
kubectl create secret tls letsencrypt-wildcard \
--cert=/tmp/tls-fullchain.crt \
--key=/tmp/tls.key \
-n wikijs
Step 4: Update Wiki.js to Use Ingress
Modify the Wiki.js Helm values:
# values.yaml
image:
tag: "2.5.309"
service:
type: ClusterIP
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: wiki.minoko.life
paths:
- path: /
pathType: Prefix
tls:
- secretName: letsencrypt-wildcard
hosts:
- wiki.minoko.life
resources:
requests:
memory: 512Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 1000m
nodeSelector:
kubernetes.io/hostname: polycephala
postgresql:
enabled: false
postgresqlHost: postgresql.postgres.svc.cluster.local
postgresqlPort: 5432
postgresqlDatabase: wikijs
postgresqlUser: wikijs
Key changes:
- Service type changed from
LoadBalancertoClusterIP - Ingress enabled with TLS referencing the wildcard secret
- Version upgraded from 2.5.308 to 2.5.309
- Removed cert-manager annotation (not using cert-manager for this certificate)
Deploy:
helm upgrade wikijs requarks/wiki -n wikijs -f values.yaml --wait
Step 5: Update DNS
The wiki previously pointed to 192.168.2.204 (LoadBalancer). Update it to point to the ingress controller at 192.168.2.224.
Using the OPNsense DNS management script:
./scripts/opnsense-dns.sh remove wiki
./scripts/opnsense-dns.sh add wiki 192.168.2.224 "Wiki.js (via ingress-nginx)"
Verify:
nslookup wiki.minoko.life 192.168.2.1
Name: wiki.minoko.life
Address: 192.168.2.224
Step 6: Automate Certificate Sync
OPNsense renews Let’s Encrypt certificates at 60 days (30 days before expiry). A CronJob syncs the updated certificate to Kubernetes.
infrastructure/letsencrypt-sync/
├── sync-cronjob.yaml
├── setup-letsencrypt-sync.sh
└── README.md
The sync CronJob:
apiVersion: batch/v1
kind: CronJob
metadata:
name: letsencrypt-sync
namespace: letsencrypt-sync
spec:
schedule: "0 5 * * *" # Daily at 5 AM
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
serviceAccountName: cert-sync
restartPolicy: OnFailure
containers:
- name: sync
image: alpine/k8s:1.31.4
command: ["/bin/sh", "-c", "apk add --no-cache openssl > /dev/null 2>&1 && /bin/sh /scripts/sync.sh"]
env:
- name: OPNSENSE_API_KEY
valueFrom:
secretKeyRef:
name: opnsense-api
key: api-key
- name: OPNSENSE_API_SECRET
valueFrom:
secretKeyRef:
name: opnsense-api
key: api-secret
- name: OPNSENSE_HOST
value: "firewall.minoko.life:8443"
volumeMounts:
- name: scripts
mountPath: /scripts
volumes:
- name: scripts
configMap:
name: sync-script
defaultMode: 0755
The sync script fetches the certificate, builds the chain, and updates secrets in configured namespaces:
#!/bin/sh
set -e
OPNSENSE_HOST="${OPNSENSE_HOST:-firewall.minoko.life:8443}"
SECRET_NAME="letsencrypt-wildcard"
NAMESPACES="wikijs ingress-nginx"
# Fetch server certificate
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://${OPNSENSE_HOST}/api/trust/cert/search" | \
jq -r '.rows[] | select(.descr | test("minoko")) | .crt_payload' > /tmp/cert.crt
# Fetch private key
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://${OPNSENSE_HOST}/api/trust/cert/search" | \
jq -r '.rows[] | select(.descr | test("minoko")) | .prv_payload' > /tmp/cert.key
# Fetch intermediate CA
curl -s -k -u "${OPNSENSE_API_KEY}:${OPNSENSE_API_SECRET}" \
"https://${OPNSENSE_HOST}/api/trust/ca/search" | \
jq -r '.rows[] | select(.descr | test("R1[0-2]")) | .crt_payload' > /tmp/intermediate.crt
# Create full chain
cat /tmp/cert.crt /tmp/intermediate.crt > /tmp/fullchain.crt
# Sync to each namespace
for NS in $NAMESPACES; do
CURRENT_HASH=$(kubectl get secret "$SECRET_NAME" -n "$NS" -o jsonpath='{.data.tls\.crt}' 2>/dev/null | md5sum | cut -d' ' -f1 || echo "none")
NEW_HASH=$(base64 -w0 /tmp/fullchain.crt | md5sum | cut -d' ' -f1)
if [ "$CURRENT_HASH" = "$NEW_HASH" ]; then
echo "Namespace $NS: Certificate unchanged, skipping"
continue
fi
kubectl delete secret "$SECRET_NAME" -n "$NS" 2>/dev/null || true
kubectl create secret tls "$SECRET_NAME" \
--cert=/tmp/fullchain.crt \
--key=/tmp/cert.key \
-n "$NS"
echo "Namespace $NS: Certificate updated"
done
RBAC requirements:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cert-sync
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
Installation:
./infrastructure/letsencrypt-sync/setup-letsencrypt-sync.sh
Verification
Test HTTPS:
curl -s https://wiki.minoko.life -o /dev/null -w "Status: %{http_code}, SSL: %{ssl_verify_result}\n"
Status: 200, SSL: 0
SSL: 0 indicates successful certificate verification.
Check certificate chain:
echo | openssl s_client -connect 192.168.2.224:443 -servername wiki.minoko.life 2>/dev/null | \
grep -E "subject|issuer|Verify"
subject=CN=minoko.life
issuer=C=US, O=Let's Encrypt, CN=R12
Verify return code: 0 (ok)
Manual sync:
kubectl create job --from=cronjob/letsencrypt-sync manual-sync -n letsencrypt-sync
kubectl logs -n letsencrypt-sync -l job-name=manual-sync
Gotchas
Linkerd Sidecars on Jobs
Kubernetes Jobs with Linkerd injection never complete because the sidecar container keeps running after the main container exits. Disable injection for Job pods:
podAnnotations:
linkerd.io/inject: disabled
Certificate Chain Required
A server certificate alone causes browser warnings. The intermediate CA must be concatenated to form the full chain:
cat server.crt intermediate.crt > fullchain.crt
Alpine Date Parsing
The sync script uses Alpine Linux. BusyBox’s date command doesn’t support -d for parsing arbitrary date strings. Expiry calculations requiring date parsing need alternative approaches or installation of GNU coreutils.
OPNsense API Special Characters
OPNsense API keys often contain +, /, and = characters. These work with curl -u (HTTP Basic Auth) without URL encoding when properly quoted in shell scripts.
Certificate Renewal Timeline
Day 0: Let's Encrypt issues cert (valid 90 days)
Day 60: OPNsense renews cert (old cert still valid 30 days)
Day 61: Daily sync picks up new cert (5 AM)
Old cert had 29 days remaining - no service interruption
The 30-day overlap between OPNsense renewal and certificate expiry ensures continuous coverage even if a few syncs fail.
Summary
| Component | Before | After |
|---|---|---|
| URL | http://192.168.2.204 | https://wiki.minoko.life |
| Service Type | LoadBalancer | ClusterIP via Ingress |
| TLS | None | Let’s Encrypt wildcard |
| Certificate Source | N/A | OPNsense ACME |
| Renewal | N/A | Automatic (daily sync) |
New infrastructure components:
infrastructure/ingress-nginx/- NGINX Ingress Controllerinfrastructure/letsencrypt-sync/- Certificate sync CronJob
Configuration available at k8s-configs.