Deploying Ingress with cert-manager and Let's Encrypt

This document will be the basis for deploying ingress on a Kubernetes cluster. We will demonstrate both Ingress NGINX Controller and traefik with cert-manager and Let's Encrypt. The demonstration will use normal deployment as well as Helm Chart deployment. We will also use the whoami application as an example of an application which also give information about headers and client IP.

We will also indicate how to bind one of the /29 IPs given to each client when they order a managed Kubernetes cluster.

Version at time of writing are : cert-manager: v1.13.2, NGINX Controller: controller-v1.9.4, traefik: v2.10.5

cert-manager

We will start with cert-manager. This will be a basic installation with no customization needed and is agnostic of the ingress controller used.

Helm

We add the repo to be able to deploy from it

$  helm repo add jetstack https://charts.jetstack.io

"jetstack" has been added to your repositories

$ helm repo update

...Successfully got an update from the "jetstack" chart repository

cert-manager requires a number of Custom resource definitions (CRDs) to be installed to work so we need to add parameter to the helm deployment.

To install with the CRDs we need to execute this command

$ helm install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.13.2 \
  --set installCRDs=true # <- this will install the custom resources (CRDs)


NAME: cert-manager
LAST DEPLOYED: Mon Oct 30 14:34:09 2023
NAMESPACE: cert-manager
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
cert-manager v1.13.2 has been deployed successfully!

In order to begin issuing certificates, you will need to set up a ClusterIssuer
or Issuer resource (for example, by creating a 'letsencrypt-staging' issuer).

More information on the different types of issuers and how to configure them
can be found in our documentation:

https://cert-manager.io/docs/configuration/

For information on how to configure cert-manager to automatically provision
Certificates for Ingress resources, take a look at the `ingress-shim`
documentation:

https://cert-manager.io/docs/usage/ingress/

kubectl deployment

This is the default static way which also include the CRDs

$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.2/cert-manager.yaml

namespace/cert-manager created
customresourcedefinition.apiextensions.k8s.io/certificaterequests.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/challenges.acme.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/clusterissuers.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/issuers.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/orders.acme.cert-manager.io created
serviceaccount/cert-manager-cainjector created
serviceaccount/cert-manager created
serviceaccount/cert-manager-webhook created
configmap/cert-manager created
configmap/cert-manager-webhook created
clusterrole.rbac.authorization.k8s.io/cert-manager-cainjector created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-issuers created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-clusterissuers created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-certificates created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-orders created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-challenges created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-ingress-shim created
clusterrole.rbac.authorization.k8s.io/cert-manager-cluster-view created
clusterrole.rbac.authorization.k8s.io/cert-manager-view created
clusterrole.rbac.authorization.k8s.io/cert-manager-edit created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-approve:cert-manager-io created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-certificatesigningrequests created
clusterrole.rbac.authorization.k8s.io/cert-manager-webhook:subjectaccessreviews created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-cainjector created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-issuers created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-clusterissuers created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-certificates created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-orders created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-challenges created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-ingress-shim created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-approve:cert-manager-io created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-certificatesigningrequests created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-webhook:subjectaccessreviews created
role.rbac.authorization.k8s.io/cert-manager-cainjector:leaderelection created
role.rbac.authorization.k8s.io/cert-manager:leaderelection created
role.rbac.authorization.k8s.io/cert-manager-webhook:dynamic-serving created
rolebinding.rbac.authorization.k8s.io/cert-manager-cainjector:leaderelection created
rolebinding.rbac.authorization.k8s.io/cert-manager:leaderelection created
rolebinding.rbac.authorization.k8s.io/cert-manager-webhook:dynamic-serving created
service/cert-manager created
service/cert-manager-webhook created
deployment.apps/cert-manager-cainjector created
deployment.apps/cert-manager created
deployment.apps/cert-manager-webhook created
mutatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created
validatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created

More details and other information can be found on the official site : https://cert-manager.io/docs/

NGINX Ingress

We will now deploy the NGINX ingress

Helm

For the ingress to work properly we need to customize it to play nice with our Cloud Controller Manager (CCM). we will need to create a values.yml to add to the parameter needed. We are using a Loadbalancer name to make it easier for both the ingress and the certificate manager to function as expected. You will need to create the DNS entry for this domain.

---
controller: 
  config:
      use-proxy-protocol: 'true'
  hostNetwork: false
  ingressClass: nginx
  service:
    annotations:
      service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true'
      service.beta.kubernetes.io/cloudstack-load-balancer-hostname: 'workaround.example.org'

To bind the ingress controller to a specific IP we can add the setting loadBalancerIP: '<IP_FROM_SUBNET_HERE>'

for example

---
controller: 
  config:
      use-proxy-protocol: 'true'
  ingressClass: nginx
  service:
    annotations:
      service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true'
      service.beta.kubernetes.io/cloudstack-load-balancer-hostname: 'workaround.example.org'
    loadBalancerIP: '111.222.333.444' # <- To specify the IP used for all ingress created under the controller.


We then install the controller

$ helm install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace -f values.yml

NAME: ingress-nginx
LAST DEPLOYED: Mon Oct 30 15:08:57 2023
NAMESPACE: ingress-nginx
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
The ingress-nginx controller has been installed.
It may take a few minutes for the Loadbalancer IP to be available.
You can watch the status by running 'kubectl --namespace ingress-nginx get services -o wide -w ingress-nginx-controller'

An example Ingress that makes use of the controller:
  apiVersion: networking.k8s.io/v1
  kind: Ingress
  metadata:
    name: example
    namespace: foo
  spec:
    ingressClassName: nginx
    rules:
      - host: www.example.org
        http:
          paths:
            - pathType: Prefix
              backend:
                service:
                  name: exampleService
                  port:
                    number: 80
              path: /
    # This section is only required if TLS is to be enabled for the Ingress
    tls:
      - hosts:
        - www.example.org
        secretName: example-tls

If TLS is enabled for the Ingress, a Secret containing the certificate and key must also be provided:

  apiVersion: v1
  kind: Secret
  metadata:
    name: example-tls
    namespace: foo
  data:
    tls.crt: <base64 encoded cert>
    tls.key: <base64 encoded key>
  type: kubernetes.io/tls

We then can validate that the annotation is correct and the Load balancer was configure

$ kubectl describe svc ingress-nginx-controller -n ingress-nginx

Name:                     ingress-nginx-controller
Namespace:                ingress-nginx
Labels:                   app.kubernetes.io/component=controller
                          app.kubernetes.io/instance=ingress-nginx
                          app.kubernetes.io/managed-by=Helm
                          app.kubernetes.io/name=ingress-nginx
                          app.kubernetes.io/part-of=ingress-nginx
                          app.kubernetes.io/version=1.9.4
                          helm.sh/chart=ingress-nginx-4.8.3
Annotations:              meta.helm.sh/release-name: ingress-nginx
                          meta.helm.sh/release-namespace: ingress-nginx
                          service.beta.kubernetes.io/cloudstack-load-balancer-hostname: workaround.example.org # <-- This need to show up
                          service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: true # <-- This need to show up
Selector:                 app.kubernetes.io/component=controller,app.kubernetes.io/instance=ingress-nginx,app.kubernetes.io/name=ingress-nginx
Type:                     Loadbalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       <IP_ADDRESS>
IPs:                      <IP_ADDRESS>
IP:                       <PUBLIC_IP_ADDRESS>
Loadbalancer Ingress:     workaround.example.org
Port:                     http  80/TCP
TargetPort:               http/TCP
NodePort:                 http  30542/TCP
Endpoints:                <IP_ADDRESS>:80
Port:                     https  443/TCP
TargetPort:               https/TCP
NodePort:                 https  30832/TCP
Endpoints:                <IP_ADDRESS>:443
Session Affinity:         None
External Traffic Policy:  Cluster
Events:
  Type    Reason                Age    From                Message
  ----    ------                ----   ----                -------
  Normal  EnsuringLoadbalancer  4m36s  service-controller  Ensuring Loadbalancer # <-- This indicate that the cluster was able to communicate with the Cloudstack Loadbalancer
  Normal  EnsuredLoadbalancer   4m19s  service-controller  Ensured Loadbalancer  # <-- This indicate that the cluster was able to configure and obtain an External IP with the Cloudstack Loadbalancer

Everything is deployed as expected.

kubectl deployment

We will need to download the controller yaml file from the official github and then modify it so that it will work with our CloudStack load balancer.

$ curl https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.4/deploy/static/provider/cloud/deploy.yaml -o ingress-nginx-controller.yml

Then edit ingress-nginx-controller.yml to add these parameter to the yaml file

[...]
apiVersion: v1
data:
  allow-snippet-annotations: 'false'
  use-proxy-protocol: 'true' # <-- Add this parameter as this will enable proxy mode between the ingress and the Loadbalancer kind: ConfigMap
metadata:
[...]
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.9.4
  name: ingress-nginx-controller
  annotations:
    service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true' # <-- Add this so the Loadbalancer is configure with TCP PROXY
    service.beta.kubernetes.io/cloudstack-load-balancer-hostname: 'workaround.example.org' # <-- Add this so that kube-proxy does not hijack traffic
  namespace: ingress-nginx

Example with a specified IP 

[...]
apiVersion: v1
data:
  allow-snippet-annotations: 'false'
  use-proxy-protocol: 'true' # <-- Add this parameter as this will enable proxy mode between the ingress and the Loadbalancer
kind: ConfigMap
metadata:
[...]
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.9.4
  name: ingress-nginx-controller
  annotations:
    service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true' # <-- Add this so the Loadbalancer is configure with TCP PROXY
    service.beta.kubernetes.io/cloudstack-load-balancer-hostname: 'workaround.example.org' # <-- Add this so that kube-proxy does not hijack traffic
  namespace: ingress-nginx
spec:
  externalTrafficPolicy: Local
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - appProtocol: http
    name: http
    port: 80
    protocol: TCP
    targetPort: http
  - appProtocol: https
    name: https
    port: 443
    protocol: TCP
    targetPort: https
  selector:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
  type: LoadBalancer
  loadBalancerIP: '111.222.333.444' # <- To specify the IP used for all ingress created under the controller.  

Then we deploy the modified yaml

$  kubectl apply -f ingress/nginx/ingress-nginx-controller.yml

namespace/ingress-nginx configured
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
networkpolicy.networking.k8s.io/ingress-nginx-admission created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created

We can validate that everything is deploy correctly

$ kubectl describe svc ingress-nginx-controller -n ingress-nginx

Name:                     ingress-nginx-controller
Namespace:                ingress-nginx
Labels:                   app.kubernetes.io/component=controller
                          app.kubernetes.io/instance=ingress-nginx
                          app.kubernetes.io/managed-by=Helm
                          app.kubernetes.io/name=ingress-nginx
                          app.kubernetes.io/part-of=ingress-nginx
                          app.kubernetes.io/version=1.9.4
                          helm.sh/chart=ingress-nginx-4.8.3
Annotations:              meta.helm.sh/release-name: ingress-nginx
                          meta.helm.sh/release-namespace: ingress-nginx
                          service.beta.kubernetes.io/cloudstack-load-balancer-hostname: workaround.example.org # <-- This need to show up
                          service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: true # <-- This need to show up
Selector:                 app.kubernetes.io/component=controller,app.kubernetes.io/instance=ingress-nginx,app.kubernetes.io/name=ingress-nginx
Type:                     Loadbalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       <IP_ADDRESS>
IPs:                      <IP_ADDRESS>
IP:                       <PUBLIC_IP_ADDRESS>
Loadbalancer Ingress:     workaround.example.org
Port:                     http  80/TCP
TargetPort:               http/TCP
NodePort:                 http  30542/TCP
Endpoints:                <IP_ADDRESS>:80
Port:                     https  443/TCP
TargetPort:               https/TCP
NodePort:                 https  30832/TCP
Endpoints:                <IP_ADDRESS>:443
Session Affinity:         None
External Traffic Policy:  Cluster
Events:
  Type    Reason                Age    From                Message
  ----    ------                ----   ----                -------
  Normal  EnsuringLoadbalancer  4m36s  service-controller  Ensuring Loadbalancer # <-- This indicate that the cluster was able to communicate with the Cloudstack Load balancer
  Normal  EnsuredLoadbalancer   4m19s  service-controller  Ensured Loadbalancer  # <-- This indicate that the cluster was able to configure and obtain an External IP with the Cloudstack Load balancer

Cluster Issuer

We will now install let's encrypt as our certificate issuer. We will use the http01 challenge to validate the control of the domain in question.

First let's create the yaml file. In this example we will do both the staging and the production issuer. For the rest of the demonstration we will use the staging Issuer.

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
  namespace: cert-manager
spec:
  acme:
    # The ACME server URL
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: issuer@example.org
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-staging
    # Enable the HTTP-01 challenge provider
    solvers:
    - http01:
        ingress:
          class: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    # The ACME server URL
    server: https://acme-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: issuer@example.org
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-prod
    # Enable the HTTP-01 challenge provider
    solvers:
    - http01:
        ingress:
          class: nginx

And then we deploy them

$ kubectl create -f ingress/nginx/cluster-issuer.yml

clusterissuer.cert-manager.io/letsencrypt-staging created
clusterissuer.cert-manager.io/letsencrypt-prod created

We can validate

$ kubectl get clusterissuers -A -owide

NAME                  READY   STATUS                                                 AGE
letsencrypt-prod      True    The ACME account was registered with the ACME server   3m5s
letsencrypt-staging   True    The ACME account was registered with the ACME server   3m5s

We can see that we are now register with the ACME server and ready to issue certificate.

whoami

We will then deploy an application and create an ingress which in turn will create and apply a certificate to it.

First deployment of the application

helm

We add the repo for the application

$ helm repo add cowboysysop https://cowboysysop.github.io/charts/

"cowboysysop" has been added to your repositories

$ helm repo update

...Successfully got an update from the "cowboysysop" chart repository


We will create a values.yml file with the ingress details needed.

ingress:
  ## @param ingress.enabled Enable ingress controller resource
  enabled: true
  ## @param ingress.ingressClassName IngressClass that will be be used to implement the Ingress
  ingressClassName: "nginx"
  ## @param ingress.pathType Ingress path type
  pathType: ImplementationSpecific
  ## @param ingress.annotations Ingress annotations
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-staging"
  ## @param ingress.hosts[0].host Hostname to your Whoami installation
  ## @param ingress.hosts[0].paths Paths within the url structure
  hosts:
    - host: whoami.example.org
      paths:
        - /
  ## @param ingress.tls TLS configuration
  tls:
    - secretName: whoami-tls
      hosts:
        - whoami.example.org

We then deploy the app

$ helm install whoami cowboysysop/whoami -f values.yml

NAME: whoami
LAST DEPLOYED: Tue Oct 31 09:25:47 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
  https://whoami.example.org/


Then we can look at the result with a browser or we can use curl

Hostname: whoami-648cb6db54-kg9tz
IP: 127.0.0.1
IP: ::1
IP: <POD_IP_WHOAMI>
IP: fe80::90b6:1ff:fec0:a147
RemoteAddr: <NGINX_INGRESS_CONTROLLER_IP>:50650
GET / HTTP/1.1
Host: whoami.example.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: en-CA,en;q=0.9,fr-CA;q=0.8,fr;q=0.7
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "macOS"
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
X-Forwarded-For: <MY_PUBLIC_CLIENT_IP_HERE>
X-Forwarded-Host: whoami.example.org
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Scheme: https
X-Real-Ip: <MY_PUBLIC_CLIENT_IP_HERE>
X-Request-Id: a995503bf15c003d2d4e26f3e674983c
X-Scheme: https

We can also look at the complete chain

# We validate the certificate
$ kubectl get certificates.cert-manager.io -A -owide

NAMESPACE   NAME         READY   SECRET       ISSUER                STATUS                                          AGE
default     whoami-tls   True    whoami-tls   letsencrypt-staging   Certificate is up to date and has not expired   6m13s

# We can examine the certifcate
$ kubectl describe certificates.cert-manager.io whoami-tls

[...]
Events:
  Type    Reason     Age   From                                       Message
  ----    ------     ----  ----                                       -------
  Normal  Issuing    11m   cert-manager-certificates-trigger          Issuing certificate as Secret does not exist
  Normal  Generated  11m   cert-manager-certificates-key-manager      Stored new private key in temporary Secret resource "whoami-tls-ks569"
  Normal  Requested  11m   cert-manager-certificates-request-manager  Created new CertificateRequest resource "whoami-tls-1"
  Normal  Issuing    10m   cert-manager-certificates-issuing          The certificate has been successfully issued

# If there is any error we can troubleshoot with the follwing commands:

# We can check the certificate request
$ kubectl get certificaterequests.cert-manager.io -A -owide

NAMESPACE   NAME           APPROVED   DENIED   READY   ISSUER                REQUESTOR                                         STATUS                                         AGE
default     whoami-tls-1   True                True    letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   Certificate fetched from issuer successfully   3m58s

# We can also check the order
$ kubectl get orders.acme.cert-manager.io -A -owide

NAMESPACE   NAME                     STATE   ISSUER                REASON   AGE
default     whoami-tls-1-540199672   valid   letsencrypt-staging            8m50s

# Finally we can also see the challenge used and the result of it

$ kubectl get challenges

[...]
NAME                                 STATE     DOMAIN            REASON                                     AGE
example-com-2745722290-4391602865-0  pending   example.org       Waiting for http-01 challenge propagation   22s

$ kubectl describe challenge example-com-2745722290-4391602865-0

[...]
Status:
  Presented:   true
  Processing:  true
  Reason:      Waiting for http-01 challenge propagation
  State:       pending
Events:
  Type    Reason     Age   From          Message
  ----    ------     ----  ----          -------
  Normal  Started    19s   cert-manager  Challenge scheduled for processing
  Normal  Presented  16s   cert-manager  Presented challenge using http-01 challenge mechanism

We can also get the certificate from the website / application

$ openssl s_client -showcerts -servername whoami.example.org -connect whoami.example.org:443 </dev/null

CONNECTED(00000005)
depth=2 C = US, O = (STAGING) Internet Security Research Group, CN = (STAGING) Pretend Pear X1
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=1 C = US, O = (STAGING) Let's Encrypt, CN = (STAGING) Artificial Apricot R3
verify return:1
depth=0 CN = whoami.example.org
verify return:1
---
Certificate chain
 0 s:CN = whoami.example.org
   i:C = US, O = (STAGING) Let's Encrypt, CN = (STAGING) Artificial Apricot R3
-----BEGIN CERTIFICATE-----
[...]
-----END CERTIFICATE-----
 1 s:C = US, O = (STAGING) Let's Encrypt, CN = (STAGING) Artificial Apricot R3
   i:C = US, O = (STAGING) Internet Security Research Group, CN = (STAGING) Pretend Pear X1
-----BEGIN CERTIFICATE-----
[...]
-----END CERTIFICATE-----
 2 s:C = US, O = (STAGING) Internet Security Research Group, CN = (STAGING) Pretend Pear X1
   i:C = US, O = (STAGING) Internet Security Research Group, CN = (STAGING) Doctored Durian Root CA X3
-----BEGIN CERTIFICATE-----
[...]
-----END CERTIFICATE-----
---
Server certificate
subject=CN = whoami.example.org

issuer=C = US, O = (STAGING) Let's Encrypt, CN = (STAGING) Artificial Apricot R3

---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: RSA-PSS
Server Temp Key: X25519, 253 bits
---
SSL handshake has read 4642 bytes and written 407 bytes
Verification error: unable to get local issuer certificate
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 2048 bit
Secure Renegotiation IS NOT supported
No ALPN negotiated
Early data was not sent
Verify return code: 20 (unable to get local issuer certificate)
---
DONE

This conclude the demonstration with ingress NGINX controller.

Traefik

We will now look at the traefik ingress controller.

we need to create a values.yml file to add the relevant setting to enable proxy mode.

Helm

ports:
  web: # This would be for the port 80
    # Enable forwarded  headers information (X-Forwarded-*).
    forwardedHeaders:
      insecure: true
    proxyProtocol:
      insecure: true
  websecure: # This would be for the port 443
    # Enable forwarded  headers information (X-Forwarded-*).
    forwardedHeaders:
      insecure: true
    proxyProtocol:
      insecure: true
service:
  annotations:
    service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true'
    service.beta.kubernetes.io/cloudstack-load-balancer-hostname: 'workaround.example.org'
  spec:
    loadBalancerIP: '111.222.333.444' # <- To specify the IP used for all ingress created under the controller.

We then deploy the helm chart

$ helm install traefik traefik/traefik --namespace=traefik --create-namespace -f values.yml

NAME: traefik
LAST DEPLOYED: Tue Oct 31 12:55:08 2023
NAMESPACE: traefik
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
Traefik Proxy v2.10.5 has been deployed successfully on traefik namespace !

kubectl deployment

For the static deployment we use this yaml. It contain all the necessary ClusterRole, ServiceAccount, ClusterRoleBinding, Deployment and Service needed to work with our cluster.

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: traefik-traefik
rules:
- apiGroups:
  - extensions
  - networking.k8s.io
  resources:
  - ingressclasses
  - ingresses
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - services
  - endpoints
  - secrets
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - extensions
  - networking.k8s.io
  resources:
  - ingresses/status
  verbs:
  - update
- apiGroups:
  - traefik.io
  - traefik.containo.us
  resources:
  - ingressroutes
  - ingressroutetcps
  - ingressrouteudps
  - middlewares
  - middlewaretcps
  - tlsoptions
  - tlsstores
  - traefikservices
  - serverstransports
  verbs:
  - get
  - list
  - watch
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: traefik
  namespace: traefik
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: traefik-traefik
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: traefik-traefik
subjects:
- kind: ServiceAccount
  name: traefik
  namespace: traefik
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/instance: traefik-traefik
    app.kubernetes.io/name: traefik
  name: traefik
  namespace: traefik
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app.kubernetes.io/instance: traefik-traefik
      app.kubernetes.io/name: traefik
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    metadata:
      annotations:
        prometheus.io/path: /metrics
        prometheus.io/port: "9100"
        prometheus.io/scrape: "true"
      labels:
        app.kubernetes.io/instance: traefik-traefik
        app.kubernetes.io/name: traefik
    spec:
      containers:
      - args:
        - --global.checknewversion
        - --global.sendanonymoususage
        - --entrypoints.metrics.address=:9100/tcp
        - --entrypoints.traefik.address=:9000/tcp
        - --entrypoints.web.address=:8000/tcp
        - --entrypoints.websecure.address=:8443/tcp
        - --api.dashboard=true
        - --ping=true
        - --metrics.prometheus=true
        - --metrics.prometheus.entrypoint=metrics
        - --providers.kubernetescrd
        - --providers.kubernetesingress
        - --entrypoints.web.forwardedHeaders.insecure
        - --entrypoints.web.proxyProtocol.insecure
        - --entrypoints.websecure.http.tls=true
        - --entrypoints.websecure.forwardedHeaders.insecure
        - --entrypoints.websecure.proxyProtocol.insecure
        - --log.level=DEBUG
        - --accesslog=true
        - --accesslog.fields.defaultmode=keep
        - --accesslog.fields.headers.defaultmode=keep
        env:
        - name: POD_NAME
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.name
        - name: POD_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        image: docker.io/traefik:v2.10.5
        imagePullPolicy: IfNotPresent
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /ping
            port: 9000
            scheme: HTTP
          initialDelaySeconds: 2
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 2
        name: traefik
        ports:
        - containerPort: 9100
          name: metrics
          protocol: TCP
        - containerPort: 9000
          name: traefik
          protocol: TCP
        - containerPort: 8000
          name: web
          protocol: TCP
        - containerPort: 8443
          name: websecure
          protocol: TCP
        readinessProbe:
          failureThreshold: 1
          httpGet:
            path: /ping
            port: 9000
            scheme: HTTP
          initialDelaySeconds: 2
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 2
        resources: {}
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
          readOnlyRootFilesystem: true
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /data
          name: data
        - mountPath: /tmp
          name: tmp
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext:
        fsGroupChangePolicy: OnRootMismatch
        runAsGroup: 65532
        runAsNonRoot: true
        runAsUser: 65532
      serviceAccount: traefik
      serviceAccountName: traefik
      terminationGracePeriodSeconds: 60
      volumes:
      - emptyDir: {}
        name: data
      - emptyDir: {}
        name: tmp
---
apiVersion: v1
kind: Service
metadata:
  annotations:
    service.beta.kubernetes.io/cloudstack-load-balancer-hostname: workaround.example.org
    service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true'
  labels:
    app.kubernetes.io/instance: traefik-traefik
    app.kubernetes.io/name: traefik
  name: traefik
  namespace: traefik
spec:
  allocateLoadBalancerNodePorts: true
  ports:
  - name: web
    port: 80
    protocol: TCP
    targetPort: web
  - name: websecure
    port: 443
    protocol: TCP
    targetPort: websecure
  selector:
    app.kubernetes.io/instance: traefik-traefik
    app.kubernetes.io/name: traefik
  sessionAffinity: None
  type: LoadBalancer

If we want to specify the IP we modify the Service, the rest is the same.

---
apiVersion: v1
kind: Service
metadata:
  annotations:
    service.beta.kubernetes.io/cloudstack-load-balancer-hostname: workaround.example.org
    service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: 'true'
  labels:
    app.kubernetes.io/instance: traefik-traefik
    app.kubernetes.io/name: traefik
  name: traefik
  namespace: traefik
spec:
  allocateLoadBalancerNodePorts: true
  loadBalancerIP: '111.222.333.444' # <- To specify the IP used for all ingress created under the controller.
  ports:
  - name: web
    port: 80
    protocol: TCP
    targetPort: web
  - name: websecure
    port: 443
    protocol: TCP
    targetPort: websecure
  selector:
    app.kubernetes.io/instance: traefik-traefik
    app.kubernetes.io/name: traefik
  sessionAffinity: None
  type: LoadBalancer


We then apply the manifest.

$ kubectl apply -f ingress/traefik/traefik-deploy.yml

clusterrole.rbac.authorization.k8s.io/traefik-traefik created
serviceaccount/traefik created
clusterrolebinding.rbac.authorization.k8s.io/traefik-traefik created
deployment.apps/traefik created
service/traefik created

We can validate with the following command

$ kubectl describe svc traefik -n traefik

Name:                     traefik
Namespace:                traefik
Labels:                   app.kubernetes.io/instance=traefik-traefik
                          app.kubernetes.io/name=traefik
Annotations:              service.beta.kubernetes.io/cloudstack-load-balancer-hostname: workaround.example.org
                          service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: true
Selector:                 app.kubernetes.io/instance=traefik-traefik,app.kubernetes.io/name=traefik
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       <IP_ADDRESS>
IPs:                      <IP_ADDRESS>
IP:                       <PUBLIC_IP_ADDRESS>
LoadBalancer Ingress:     workaround.example.org
Port:                     web  80/TCP
TargetPort:               web/TCP
NodePort:                 web  30243/TCP
Endpoints:                <IP_ADDRESS>:8000
Port:                     websecure  443/TCP
TargetPort:               websecure/TCP
NodePort:                 websecure  30707/TCP
Endpoints:                <IP_ADDRESS>:8443
Session Affinity:         None
External Traffic Policy:  Cluster
Events:
  Type    Reason                Age    From                Message
  ----    ------                ----   ----                -------
  Normal  EnsuringLoadBalancer  3m19s  service-controller  Ensuring load balancer # <-- This indicate that the cluster was able to communicate with the Cloudstack Load balancer
  Normal  EnsuredLoadBalancer   3m2s   service-controller  Ensured load balancer # <-- This indicate that the cluster was able to configure and obtain an External IP with the Cloudstack Load balancer

With this result we know everything is now installed.

ClusterIssuer

As we did with the NGINX ingress we will define and deploy our clusterIssuer pointing to Let's Encrypt and specifying traefik as the ingress class and using HTTP-01 challenge for verification.

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
  namespace: cert-manager
spec:
  acme:
    # The ACME server URL
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: issuer@example.org
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-staging
    # Enable the HTTP-01 challenge provider
    solvers:
    - http01:
        ingress:
          ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    # The ACME server URL
    server: https://acme-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: issuer@example.org
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-prod
    # Enable the HTTP-01 challenge provider
    solvers:
    - http01:
        ingress:
          ingressClassName: traefik

We then deploy the manifest

$ kubectl create -f cluster-issuer.yml

clusterissuer.cert-manager.io/letsencrypt-staging created
clusterissuer.cert-manager.io/letsencrypt-prod created

We validate

$ kubectl get clusterissuers -A -owide

NAME                  READY   STATUS                                                 AGE
letsencrypt-prod      True    The ACME account was registered with the ACME server   3m5s
letsencrypt-staging   True    The ACME account was registered with the ACME server   3m5s

whoami

We will deploy a sample app to test and validate that everything is working as expected.

helm

We add the repo for the application

$ helm repo add cowboysysop https://cowboysysop.github.io/charts/

"cowboysysop" has been added to your repositories

$ helm repo update

...Successfully got an update from the "cowboysysop" chart repository


We will create a values.yml file with the ingress details needed.

ingress:
  ## @param ingress.enabled Enable ingress controller resource
  enabled: true
  ## @param ingress.ingressClassName IngressClass that will be be used to implement the Ingress
  ingressClassName: "traefik"
  ## @param ingress.pathType Ingress path type
  pathType: ImplementationSpecific
  ## @param ingress.annotations Ingress annotations
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-staging"
  ## @param ingress.hosts[0].host Hostname to your Whoami installation
  ## @param ingress.hosts[0].paths Paths within the url structure
  hosts:
    - host: whoami.example.org
      paths:
        - /
  ## @param ingress.tls TLS configuration
  tls:
    - secretName: whoami-traefik-tls
      hosts:
        - whoami.example.org

We then deploy the app

$ helm install whoami cowboysysop/whoami -f values.yml

NAME: whoami
LAST DEPLOYED: Tue Oct 31 09:25:47 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
  https://whoami.example.org/


Then we can look at the result with a browser

Hostname: whoami-traefik-7ffbbd6c56-gzx2b
IP: 127.0.0.1
IP: ::1
IP: <IP_WHOAMI_APP>
IP: fe80::78b8:2eff:fe71:e455
RemoteAddr: <TRAEFIK_INGRESS_CONTROLLER_IP>:34628
GET / HTTP/1.1
Host: whoami.example.org
User-Agent: curl/7.85.0
Accept: */*
Accept-Encoding: gzip
X-Forwarded-For: <MY_PUBLIC_CLIENT_IP_HERE>
X-Forwarded-Host: whoami.example.org
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: traefik-74f4c67788-ntm9r
X-Real-Ip: <MY_PUBLIC_CLIENT_IP_HERE>

We can examine the complete chain.

# We validate the certificate
$ kubectl get certificates.cert-manager.io -A -owide

NAMESPACE   NAME                 READY   SECRET               ISSUER                STATUS                                          AGE
default     whoami-traefik-tls   True    whoami-traefik-tls   letsencrypt-staging   Certificate is up to date and has not expired   21h

# We can examine the certifcate
$ kubectl describe certificates.cert-manager.io whoami-tls

[...] Status:
  Conditions:
    Last Transition Time:  2023-10-31T15:40:44Z
    Message:               Certificate is up to date and has not expired
    Observed Generation:   2
    Reason:                Ready
    Status:                True
    Type:                  Ready
  Not After:               2024-01-29T14:40:39Z
  Not Before:              2023-10-31T14:40:40Z
  Renewal Time:            2023-12-30T14:40:39Z
  Revision:                1


# If there is any error we can troubleshoot with the following commands:

# We can also check the certificate request
$ kubectl get certificaterequests.cert-manager.io -A -owide

NAMESPACE   NAME                   APPROVED   DENIED   READY   ISSUER                REQUESTOR                                         STATUS                                         AGE
default     whoami-traefik-tls-1   True                True    letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   Certificate fetched from issuer successfully   21h

# We can also check the order
$ kubectl get orders.acme.cert-manager.io -A -owide

NAMESPACE   NAME                             STATE   ISSUER                REASON   AGE
default     whoami-traefik-tls-1-686561438   valid   letsencrypt-staging            21h  # Finally we can also see the challenge used and the result of it

$ kubectl get challenges

[...]
NAME                                 STATE     DOMAIN            REASON                                     AGE
example-com-2745722290-4391602865-0  pending   example.org       Waiting for http-01 challenge propagation   22s

$ kubectl describe challenge example-com-2745722290-4391602865-0

[...]
Status:
  Presented:   true
  Processing:  true
  Reason:      Waiting for http-01 challenge propagation
  State:       pending
Events:
  Type    Reason     Age   From          Message
  ----    ------     ----  ----          -------
  Normal  Started    19s   cert-manager  Challenge scheduled for processing
  Normal  Presented  16s   cert-manager  Presented challenge using http-01 challenge mechanism

Get Support

Need Technical Support?

Have a specific challenge with your setup?

Create a Ticket