Part 8: Valid SSL at Home — pfSense CA, Traefik, and Trusted Internal HTTPS

k3straefikpfsensetlssslhomelab

Homelab Arena Part 8: Valid SSL at Home

Part 7 made the cluster feel closer to a platform. Applications started to follow the same shape: they were defined in Git, deployed through Argo CD, persisted through a common storage pattern, and backed up outside the cluster. That solved a big part of the operational story.

There was still one very visible problem left. Every internal HTTPS page looked suspicious to the browser.

The services were reachable through local names like:

tools.c.home
ghost.c.home
argocd.c.home

DNS worked. Traefik worked. The applications loaded. The problem was that the browser still treated them like unsafe websites because Traefik was serving its default certificate. That default certificate is useful as a fallback, but it is not trusted by laptops, phones, or browsers.

This became more than a cosmetic issue. On my laptop, the Traefik default certificate and Chrome combination meant the page would regularly fall back into a warning flow. Sometimes it felt like the page needed to be refreshed through a browser warning every few minutes. For me, that was annoying. For my kids, it was a terrible user experience. A home service should feel like it belongs on the home network, not like something everyone has to bypass with a scary warning screen.

This part adds a proper internal SSL setup for the homelab. pfSense becomes the internal certificate authority, Traefik serves a wildcard certificate for the home domain, and client devices trust that CA.

Homelab Arena Part 8 - Brought to you by Scatman


The trust layer

The core problem was trust, not routing. The browser could reach the service, but it had no reason to trust the certificate being presented by Traefik.

For public websites, this is handled by public certificate authorities and services like Let’s Encrypt. For this homelab, the services live under an internal .home domain, so public certificate authorities are not the right fit. I do not want to expose these services publicly just to get certificates, and I do not want to click through browser warnings forever.

The home network needs its own internal certificate authority. pfSense is already a central piece of the network, so it is a good place to manage that internal CA. The CA signs a wildcard certificate for *.c.home, Traefik serves that certificate, and each client device trusts the CA.

The final flow looks like this:

Client device
  trusts home-ca.crt
        |
        v
https://tools.c.home
        |
        v
Local DNS
  tools.c.home -> Traefik IP
        |
        v
k3s Traefik
  serves *.c.home certificate
        |
        v
Kubernetes application

The important distinction is that client devices trust the CA certificate, while Traefik serves a wildcard certificate signed by that CA.

Creating the CA in pfSense

In pfSense, the certificate manager is under:

System -> Cert Manager -> CAs

Create a new internal certificate authority with values like these:

Descriptive name: home-ca
Method: Create an internal Certificate Authority
Key type: RSA
Key length: 2048 or 4096
Lifetime: 3650
Common Name: home-ca
Trust Store: enabled
Randomize Serial: enabled

The CA becomes the internal root of trust for the home network. I use a long lifetime because every device in the house needs to trust this CA. Rotating the CA means touching every laptop, Linux machine, and phone again, so I prefer to keep the CA stable and rotate issued certificates separately if needed.

The Trust Store option makes pfSense trust this CA locally. Randomize Serial keeps issued certificate serial numbers from being predictable.


Creating the wildcard certificate

After creating the CA, create the certificate that Traefik will serve.

Go to:

System -> Cert Manager -> Certificates

Choose:

Create an internal certificate

Use the CA created earlier:

Certificate authority: home-ca

Use values like these:

Descriptive name: homelab-wildcard
Type: Server Certificate
Key type: RSA
Key length: 2048 or 4096
Lifetime: 3650
Common Name: *.c.home
Digest Algorithm: SHA256

The SAN section matters. In pfSense, the SAN type may be shown as FQDN. Add these entries:

FQDN: *.c.home
FQDN: c.home

The certificate should effectively contain:

CN: *.c.home

SAN:
  *.c.home
  c.home

Modern browsers validate hostnames through SAN entries, so this section is required for a clean browser experience.


Exporting from pfSense

After creating the CA and wildcard certificate, export three files from pfSense.

From:

System -> Cert Manager -> Certificates

export:

homelab-wildcard.crt
homelab-wildcard.key

From:

System -> Cert Manager -> CAs

export:

home-ca.crt

These files have different jobs. The CA certificate is installed on client devices. The wildcard certificate and private key are used by Traefik. The key stays outside the repository because I do not want to commit private key material to GitHub. Since the key remains local, I do not need Ansible Vault for this setup. If I ever decide to store the key in the repository, then it should be encrypted first.


Creating the fullchain certificate

Linux exposed one missing detail in my first attempt. I installed the CA on a Linux box, but curl still failed unless I used -k:

curl -vk https://tools.c.home

The -k flag skips certificate verification, so it only proves the service is reachable. It does not prove the trust chain is correct.

The fix was to make Traefik serve the full certificate chain. On the machine that holds the exported certificates, create a fullchain file:

cd ~/homelab-certs
cat homelab-wildcard.crt home-ca.crt > homelab-wildcard-fullchain.crt

After that, Traefik should use:

homelab-wildcard-fullchain.crt
homelab-wildcard.key

Client devices should use:

home-ca.crt

This made certificate verification much more consistent across browsers and Linux command-line tools.


Installing the certificate into k3s Traefik

The Kubernetes TLS secret belongs in kube-system, because this certificate is used by Traefik as the default TLS certificate.

The manual command looks like this:

kubectl create secret tls home-wildcard-tls \
  --cert=homelab-wildcard-fullchain.crt \
  --key=homelab-wildcard.key \
  -n kube-system \
  --dry-run=client -o yaml | kubectl apply -f -

Then create the Traefik default TLSStore:

cat <<'EOF' | kubectl apply -f -
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: kube-system
spec:
  defaultCertificate:
    secretName: home-wildcard-tls
EOF

Restart Traefik after applying the secret and TLS store:

kubectl rollout restart deployment/traefik -n kube-system

At this point, Traefik has a default certificate for HTTPS. Application namespaces do not need their own copy of the wildcard secret.


Making it part of the k3s Ansible flow

I wanted this to be part of the cluster setup rather than a set of manual commands I would forget later.

The certificate role runs after the k3s_server role because k3s and Traefik need to exist first. The control-plane play becomes:

---
# Order: control plane first so agents can join an API that exists.
- name: Install k3s control plane
  hosts: k3s_servers
  gather_facts: true
  pre_tasks:
    - name: Require k3s_cluster_token (from Terraform or Vault)
      ansible.builtin.assert:
        that:
          - k3s_cluster_token is defined
          - (k3s_cluster_token | length) > 0
        fail_msg: >-
          Define k3s_cluster_token (copy ansible/inventory/group_vars/all/secrets.yml.example to secrets.yml
          and set the value from: terraform output -raw k3s_cluster_token).
  roles:
    - role: k3s_common
    - role: k3s_server
    - role: traefik_tls

- name: Install k3s agents
  hosts: k3s_agents
  gather_facts: true
  pre_tasks:
    - name: Require k3s_cluster_token and k3s_server_url
      ansible.builtin.assert:
        that:
          - k3s_cluster_token is defined
          - (k3s_cluster_token | length) > 0
          - k3s_server_url is defined
          - (k3s_server_url | length) > 0
        fail_msg: >-
          Set k3s_cluster_token (inventory/group_vars/all/secrets.yml) and k3s_server_url (inventory/group_vars/k3s_agents.yml).
  roles:
    - role: k3s_common
    - role: k3s_agent

The role runs only on the k3s server. Worker nodes do not need to manage the Traefik TLS secret.


Role defaults

Create:

roles/traefik_tls/defaults/main.yml

with:

---
traefik_tls_cert_file: "{{ lookup('env', 'HOME') }}/homelab-certs/homelab-wildcard-fullchain.crt"
traefik_tls_key_file: "{{ lookup('env', 'HOME') }}/homelab-certs/homelab-wildcard.key"

traefik_tls_secret_name: home-wildcard-tls
traefik_tls_namespace: kube-system
traefik_tls_store_name: default
traefik_tls_deployment_name: traefik

# K3s normally writes kubeconfig here on the server/control-plane node.
k3s_kubeconfig_path: /etc/rancher/k3s/k3s.yaml

# Restart Traefik after cert/secret changes so the new default cert is picked up reliably.
traefik_tls_restart_traefik: true

The role reads the certificate and key from the Ansible control machine, copies them temporarily to the k3s server, creates the Kubernetes secret, applies the Traefik TLS store, and removes the temporary files.


Role tasks

Create:

roles/traefik_tls/tasks/main.yml

with:

---
- name: Check local wildcard certificate file exists
  ansible.builtin.stat:
    path: "{{ traefik_tls_cert_file }}"
  delegate_to: localhost
  become: false
  register: traefik_tls_cert_stat

- name: Check local wildcard private key file exists
  ansible.builtin.stat:
    path: "{{ traefik_tls_key_file }}"
  delegate_to: localhost
  become: false
  register: traefik_tls_key_stat

- name: Require local wildcard cert and key files
  ansible.builtin.assert:
    that:
      - traefik_tls_cert_stat.stat.exists
      - traefik_tls_key_stat.stat.exists
    fail_msg: >-
      Missing wildcard cert/key files. Export them from pfSense and place them at
      traefik_tls_cert_file={{ traefik_tls_cert_file }} and
      traefik_tls_key_file={{ traefik_tls_key_file }}, or override these variables.

- name: Create temporary Traefik TLS directory on server
  ansible.builtin.file:
    path: /tmp/traefik-tls
    state: directory
    mode: "0700"

- name: Copy wildcard certificate to server
  ansible.builtin.copy:
    src: "{{ traefik_tls_cert_file }}"
    dest: /tmp/traefik-tls/tls.crt
    mode: "0600"

- name: Copy wildcard private key to server
  ansible.builtin.copy:
    src: "{{ traefik_tls_key_file }}"
    dest: /tmp/traefik-tls/tls.key
    mode: "0600"
  no_log: true

- name: Create or update Traefik wildcard TLS secret
  ansible.builtin.shell: |
    set -euo pipefail
    kubectl --kubeconfig {{ k3s_kubeconfig_path }} \
      create secret tls {{ traefik_tls_secret_name }} \
      --cert=/tmp/traefik-tls/tls.crt \
      --key=/tmp/traefik-tls/tls.key \
      -n {{ traefik_tls_namespace }} \
      --dry-run=client -o yaml | \
    kubectl --kubeconfig {{ k3s_kubeconfig_path }} apply -f -
  args:
    executable: /bin/bash
  no_log: true
  register: traefik_tls_secret_result
  changed_when: "'configured' in traefik_tls_secret_result.stdout or 'created' in traefik_tls_secret_result.stdout"

- name: Configure Traefik default TLSStore
  ansible.builtin.shell: |
    set -euo pipefail
    cat <<EOF | kubectl --kubeconfig {{ k3s_kubeconfig_path }} apply -f -
    apiVersion: traefik.io/v1alpha1
    kind: TLSStore
    metadata:
      name: {{ traefik_tls_store_name }}
      namespace: {{ traefik_tls_namespace }}
    spec:
      defaultCertificate:
        secretName: {{ traefik_tls_secret_name }}
    EOF
  args:
    executable: /bin/bash
  register: traefik_tls_store_result
  changed_when: "'configured' in traefik_tls_store_result.stdout or 'created' in traefik_tls_store_result.stdout"

- name: Restart Traefik when TLS resources changed
  ansible.builtin.command: >
    kubectl --kubeconfig {{ k3s_kubeconfig_path }}
    rollout restart deployment/{{ traefik_tls_deployment_name }}
    -n {{ traefik_tls_namespace }}
  when:
    - traefik_tls_restart_traefik | bool
    - traefik_tls_secret_result.changed or traefik_tls_store_result.changed
  changed_when: true

- name: Remove temporary Traefik TLS files from server
  ansible.builtin.file:
    path: /tmp/traefik-tls
    state: absent

Run the normal playbook:

ansible-playbook -i inventory/hosts.yml playbooks/site.yml

This keeps the Traefik certificate setup tied to cluster bootstrap instead of leaving it as a manual afterthought.


Checking the fullchain

The secret should contain two certificates: the wildcard certificate and the CA certificate.

Check it with:

kubectl get secret home-wildcard-tls -n kube-system \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | grep "BEGIN CERTIFICATE" -c

Expected output:

2

If the output is 1, the secret only contains the leaf certificate. Some clients may still accept that, but Linux verification can fail. The fullchain keeps behavior consistent.


Enabling TLS for applications

Applications do not need to manage certificates. The Deployment stays the same, the Service stays the same, and the Ingress only needs TLS enabled for the hostname.

A typical Ingress looks like this:

spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - tools.c.home
  rules:
    - host: tools.c.home

There is no secretName in the application Ingress. Traefik uses the default TLSStore certificate, which is the pfSense-signed wildcard certificate created earlier.

The app asks for HTTPS, and Traefik serves the default certificate.


Trusting the CA on macOS

On macOS, install the CA certificate:

home-ca.crt

Use this command:

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain \
  ~/Downloads/home-ca.crt

It can also be done through Keychain Access:

Applications -> Utilities -> Keychain Access

Then select:

System keychain
Certificates
home-ca
Trust
When using this certificate: Always Trust

This step was easy to miss. The certificate can be installed while still showing:

This certificate is marked as not trusted for all users

After setting the CA to Always Trust, Safari stopped warning. Chrome worked after that too.


Trusting the CA on Linux

For Debian, Ubuntu, and Linux Lite, copy the CA certificate into the system certificate directory:

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

The command should report something like:

1 added, 0 removed

Then test with:

curl -v https://tools.c.home

The -k flag should only be used for debugging:

curl -vk https://tools.c.home

If curl -vk works and curl -v fails, the service is reachable but trust verification is still incomplete. The usual causes are a missing CA on the client or Traefik serving only the leaf certificate instead of the fullchain.


Browser trust stores on Linux

Linux has one extra wrinkle: system tools and browsers may not use the same trust store. The system CA store is enough for tools like curl, but some browsers and applications also use NSS certificate databases such as cert8.db and cert9.db.

For that part, I used a script based on Thomas Leister’s guide:

https://thomas-leister.de/en/how-to-import-ca-root-certificate/

Install certutil first:

sudo apt update
sudo apt install -y libnss3-tools

Then create the helper script:

cat > install-home-ca-nss.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

certfile="${1:-home-ca.crt}"
certname="${2:-Home CA}"

if [ ! -f "$certfile" ]; then
  echo "Missing cert file: $certfile"
  exit 1
fi

# Firefox/Chrome legacy DBM profiles
find "$HOME" -name "cert8.db" -print0 | while IFS= read -r -d '' certDB; do
  certdir="$(dirname "$certDB")"
  echo "Installing into DBM: $certdir"
  certutil -A -n "$certname" -t "TCu,Cu,Tu" -i "$certfile" -d "dbm:$certdir" || true
done

# Firefox/Chrome SQL profiles
find "$HOME" -name "cert9.db" -print0 | while IFS= read -r -d '' certDB; do
  certdir="$(dirname "$certDB")"
  echo "Installing into SQL: $certdir"
  certutil -D -n "$certname" -d "sql:$certdir" 2>/dev/null || true
  certutil -A -n "$certname" -t "TCu,Cu,Tu" -i "$certfile" -d "sql:$certdir"
done

echo "Done. Restart browsers."
EOF

chmod +x install-home-ca-nss.sh

Run it with the CA certificate:

./install-home-ca-nss.sh /path/to/home-ca.crt "Home CA"

After that, restart the browsers so they reload their trust stores.


Trusting the CA on Android

This setup also works well on Android because Chrome and Brave use the Android system credential store.

First, transfer the CA certificate to the phone:

home-ca.crt

On Android, open:

Settings
  -> Security & privacy
  -> More security settings
  -> Encryption & credentials

Choose the option to install a certificate, then select the CA certificate file. Android may show a strong warning because installing a CA means the device will trust certificates signed by that authority. That warning is expected. In this case, the CA is the one created in pfSense for the home network.

After installing the CA, Brave and Chrome can open internal services like:

https://tools.c.home
https://argocd.c.home

without showing the certificate warning.

This was a nice checkpoint because the setup was no longer just something that worked on my laptop. The phone trusted it too, which made the homelab feel much more natural to use around the house.


What this changes

This is one of those changes that does not look dramatic in Kubernetes, but changes how the whole homelab feels to use. The applications were already there before this part. The DNS names already worked. Traefik was already routing traffic. The difference is that the browser no longer treats every internal service like a suspicious website.

That matters more than I expected. When a dashboard opens without a warning, it feels like it belongs on the network. When my kids open a page and do not have to ask why Chrome is showing a scary message, the service feels like part of the house instead of one of my experiments.

The setup is still simple from the application side. Apps only need a hostname and TLS enabled on their Ingress. Traefik handles the certificate at the edge, and the devices in the house trust the pfSense CA. Once that trust is in place, the same pattern works across macOS, Linux, Android, Safari, Chrome, and Brave.

Part 7 made application deployment repeatable. This part makes those applications feel trusted enough for everyday use.