Strong opinions loosely held

A blog by Jan Willhaus

How to set up a custom domain name and TLS certificate for Plex running on Kubernetes

The holy grail: Plex playing music to a remote IPv6 client via a secure connection
The holy grail: Plex playing music to a remote IPv6 client via a secure connection

My Plex server’s remote access setup finally reached a state that I’m happy with, and I want to share that here. The idea here is: I want to expose the server outside of my home network so that I can listen to my music on the go, and do so seamlessly and via TLS encrypted IPv4 and IPv6 connections. Especially the latter is an issue that Plex has not solved yet; the official Remote Access functionality in Plex only supports IPv4. But I got there, and this is roughly how I did it:

With a Fritz!Box as a router and its MyFRITZ!Net service acting as a dynamic DNS service, exposing Plex via IPv4 and IPv6 _outside of the official Remote Access functionality is actually pretty straight-forward. The MyFRITZ!Net service gives every router a unique DNS name à la asdf1234.myfritz.net that receives updated A and AAAA records, every time the Fritz!Box receives a new IP address assignment. That way services such as DuckDNS are not necessary; all I need to do is create the correct port-forwardings, add the DNS name as a custom server access URL in Plex and presto, the server is exposed to the outside world.

But this does not solve TLS encryption for me. With MyFRITZ!Net there is no way I can present a valid TLS certificate for asdf1234.myfritz.net from my homeserver. It’s not my domain after all. Hence, all connections towards the server, while working, will be »downgraded« to unencrypted due to the certificate mismatch. Now, to get around that, I created a CNAME record pointing to asdf1234.myfritz.net. on one of the domains that I own, allowing me to have TLS certificates issued for it by Let’s Encrypt (LE). This way, I just need to give Plex the LE certificate to present.

Kubernetes setup

This is where it gets interesting though, because my homeserver is running Kubernetes, using the lightweight distro k3s. Within Kubernetes, I’m running cert-manager (with a ClusterIssuer configured for Let’s Encrypt) to make the certificate juggling possible.

Please note that the following manifest snippets show only the relevant portions required to follow along. The full manifests can be found at the end of the article:

We start off by creating a Certificate resource for the DNS name:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: plex-janw-xyz-tls
  namespace: default
spec:
  dnsNames:
    - "plex.janw.xyz"
  issuerRef:
    name: le-issuer-dns
    kind: ClusterIssuer
  secretName: plex-janw-xyz-tls

With that in place, I can now pass the certificate to Plex. Note here, that cert-manager currently does not generate PKCS#12-format certificate files but PEMs, while Plex expects PKCS#12 and does not work with PEMs. Therefore just mounting the cert’s Secret into the Plex container is not enough. We first must convert the format, and we do so in an initContainer:

initContainers:
  - name: convert-cert
    image: plexinc/pms-docker
    command: [bash]
    args:
      - c
      - |
        openssl \
          pkcs12 \
          -export \
          -out /tls-pkcs/tls.pfx \
          -in /tls-cert/tls.crt \
          -inkey /tls-cert/tls.key \
          -password pass:SOMESECRET \
          -name plex.janw.xyz \
          -certpbe AES-256-CBC \
          -keypbe AES-256-CBC \
          -macalg SHA256

        chmod 755 /tls-pkcs/tls.pfx
        ls -lh /tls-pkcs/
    volumeMounts:
      - mountPath: /tls-cert
        name: tls-cert
      - mountPath: /tls-pkcs
        name: tls-pkcs
  # …
volumes:
  # …
  - name: tls-cert
    secret:
      secretName: myplex-tls
  - name: tls-pkcs
    emptyDir: {}

The script in the initContainer will convert the certificate and place it in the tls-pkcs emptyDir volume. This volume can now be passed to the Plex container itself:

containers:
  - name: plex
    image: plexinc/pms-docker
    volumeMounts:
      # …
      - mountPath: /tls-pkcs
        name: tls-pkcs
    ports:
      - name: pms
        containerPort: 32400
volumes:
  # …
  - name: tls-cert
    secret:
      secretName: plex-janw-xyz-tls
  - name: tls-pkcs
    emptyDir: {}

Plex settings UI

Now the certificate is available to Plex at /tls-pkcs/tls.pfx, and encrypted with the password SOMESECRET. Those details can be entered in the Plex server network settings now:

Custom certificate configuration in Plex’s network settings
Custom certificate configuration in Plex’s network settings

Further down on the settings page the custom server access URL must also be adjusted. Here we need the full external URL, including the port used in the forwarding setup:

Custom server access URL configuration in Plex’s network settings
Custom server access URL configuration in Plex’s network settings

Finally we’ll have to restart Plex, because it will load the certificate only on startup. After a rollout the certificate will be picked up though, and Plex should reachable via the new external hostname.

Notes on certificate renewal

You might have noticed the statefulset’s reloader.stakater.com/auto: "true" annotation. It belongs to Stakater Reloader, a light-weight controller that automatically performs rolling updates on workloads when the ConfigMaps or Secrets they reference are updated. It is particularly useful, for example, when you are doing configuration changes in a ConfigMap but otherwise don’t modify the Deployment that references it. Kubernetes would normally not perform a rollout because the workload did not change, and if there is no dedicated mechanism to reload the config, the workload continues running with the previous configuration. Reloader practically replaces a dedicated config reload mechanism by watching the ConfigMap itself, and doing a rolling update when it changes, thus ensuring the workload adopts the new configuration.

In the setup described above I’m relying on Reloader to be a crude »certificate reloader« for the time when the Let’s Encrypt certificate is renewed. Unfortunately Plex does not support reloading its custom certificate, it is only loaded once at startup time. With the Reloader in place, the renewal will cause the certificate’s Secret to be updated, which in turn causes a new rollout of Plex, now with the renewed certificate. This is far from ideal, because the renewal can happen at any time, while you’re watching something, and that would cause the stream to be interrupted. But in my opinion it is still better than manually restarting the server whenever the renewal occurs.

Full manifests

Click here to expand the full manifests
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: plex-janw-xyz-tls
  namespace: default
spec:
  dnsNames:
    - "plex.janw.xyz"
  issuerRef:
    name: letsencrypt-issuer
    kind: ClusterIssuer
  secretName: plex-janw-xyz-tls

---
kind: StatefulSet
apiVersion: apps/v1
metadata:
  name: plex
  namespace: default
  annotations:
    reloader.stakater.com/auto: "true"
spec:
  replicas: 1
  serviceName: plex
  selector:
    matchLabels:
      app: plex
  template:
    metadata:
      labels:
        app: plex
        app.kubernetes.io/name: plex
    spec:
      hostNetwork: true
      initContainers:
        - name: convert-cert
          image: plexinc/pms-docker
          command: [bash]
          args:
            - -c
            - |
              openssl \
                pkcs12 \
                -export \
                -out /tls-pkcs/tls.pfx \
                -in /tls-cert/tls.crt \
                -inkey /tls-cert/tls.key \
                -password pass:SOMESECRET \
                -name plex.janw.xyz \
                -certpbe AES-256-CBC \
                -keypbe AES-256-CBC \
                -macalg SHA256

              chmod 755 /tls-pkcs/tls.pfx
              ls -lh /tls-pkcs/

          volumeMounts:
            - mountPath: /tls-cert
              name: tls-cert
            - mountPath: /tls-pkcs
              name: tls-pkcs
      containers:
        - name: plex
          image: plexinc/pms-docker
          resources:
          volumeMounts:
            - mountPath: /media
              name: media-vol
              readOnly: true
            - mountPath: /transcode
              name: transcode-vol
            - mountPath: /config
              name: config-vol
            - mountPath: /tls-pkcs
              name: tls-pkcs
          ports:
            - name: pms
              containerPort: 32400
          livenessProbe:
            httpGet:
              path: /identity
              port: 32400
      volumes:
        - name: media-vol
          hostPath:
            path: /mnt/media
        - name: transcode-vol
          hostPath:
            path: /mnt/cache
        - name: config-vol
          hostPath:
            path: /mnt/config
        - name: tls-cert
          secret:
            secretName: plex-janw-xyz-tls
        - name: tls-pkcs
          emptyDir: {}