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 LoadBalancer to ClusterIP
  • 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

ComponentBeforeAfter
URLhttp://192.168.2.204https://wiki.minoko.life
Service TypeLoadBalancerClusterIP via Ingress
TLSNoneLet’s Encrypt wildcard
Certificate SourceN/AOPNsense ACME
RenewalN/AAutomatic (daily sync)

New infrastructure components:

  • infrastructure/ingress-nginx/ - NGINX Ingress Controller
  • infrastructure/letsencrypt-sync/ - Certificate sync CronJob

Configuration available at k8s-configs.