In the previous lesson we published www.rutasnorte.example and api.rutasnorte.example with a single Ingress, and we closed by pointing out the problem that cannot wait: everything travels over plain HTTP. The name, the ID number, the phone and the email a customer types in to book a ticket, and the data heading for the payment gateway, are crossing the internet unencrypted. This lesson puts HTTPS on both domains: first by hand with a self-signed certificate to understand the pieces, and then automatically with cert-manager, which will issue and renew Let's Encrypt certificates without anybody having to remember a thing.

Security warning. This lesson teaches the Kubernetes mechanics for managing certificates. The real cryptographic configuration of a production platform —cipher suites, permitted TLS versions, HSTS policies, private key custody, regulatory compliance requirements— must be reviewed and approved by a security professional. The values shown here are didactic and reasonable in 2026, but they age: do not copy them into production without validating them with the appropriate people in your organisation.

Contents

  1. Why HTTPS is mandatory in Rutas Norte
  2. Where TLS terminates in a cluster
  3. The kubernetes.io/tls Secret and the Ingress tls: block
  4. A self-signed certificate for development
  5. cert-manager: the problem it solves and how to install it
  6. The cert-manager objects and the issuance flow
  7. ACME with Let's Encrypt: HTTP-01 versus DNS-01
  8. Automatic issuance from the Ingress
  9. The staging environment and the issuance limits
  10. Automatic renewal and checking the status
  11. Redirecting to HTTPS and HSTS
  12. Beyond: mTLS and service meshes

  1. Why HTTPS is mandatory in Rutas Norte

It is not a good practice: it is a requirement.

Reason What happens without TLS
Personal data Name, ID number, phone and email travel readable by any intermediate network. Under the GDPR, encryption in transit is an expected technical measure
Payments PCI DSS requires the channel to be encrypted. No serious gateway will agree to integrate over HTTP
Sessions A JWT or a cookie intercepted on public wifi is a stolen account
Integrity An intermediary can modify the response: inject scripts, change the amount
Browsers and SEO They mark it "Not secure", block APIs (geolocation, service workers) and search engines penalise HTTP. The store would look fraudulent

And one clarification: TLS does not only encrypt, it also authenticates. The certificate proves that whoever answers at www.rutasnorte.example really is Rutas Norte and not somebody who has hijacked DNS. Without that part, you would be encrypting with a stranger.

  1. Where TLS terminates in a cluster

"Terminating TLS" means decrypting: the point where traffic stops being encrypted. There are three options.

flowchart LR
    CA["Client"] -->|HTTPS| IA["A. Ingress:<br/>decrypts here"]
    IA -->|"plain HTTP"| SA["Pods"]
    CB["Client"] -->|HTTPS| IB["B. Ingress: decrypts<br/>and re-encrypts"]
    IB -->|HTTPS| SB["Pods with their own certificate"]
    CC["Client"] -->|HTTPS| IC["C. Ingress: does NOT decrypt,<br/>forwards by SNI"]
    IC -->|HTTPS| SC["The pod terminates TLS"]
Model Advantages Drawbacks When
A. Termination at the Ingress A single place with certificates; applications know nothing about TLS; the controller can route by path and headers Internal traffic goes in the clear inside the cluster The default, and what Rutas Norte uses
B. Re-encryption Encryption inside the cluster too Every application manages its own certificate Regulated environments, untrusted networks
C. Passthrough The certificate never leaves the application The Ingress does not see HTTP: no path routing, no rewriting Strict mTLS, opaque protocols

Rutas Norte uses A. Plain internal traffic is compensated for by the network policies of 04-06, and if encryption inside the cluster were needed, the answer would be a service mesh (08-04).

  1. The kubernetes.io/tls Secret and the Ingress tls: block

A certificate in Kubernetes is a Secret of type kubernetes.io/tls with exactly two keys: tls.crt, the certificate chain in PEM (the server's first, then the intermediates), and tls.key, the private key in PEM. The type forces both to exist.

Remember from 03-02: that is base64, not encryption, and anybody with read permission on Secrets in that namespace holds the platform's private key; it is the most compelling argument for the RBAC of 08-01 and for encryption at rest in etcd. The Ingress consumes it with the tls: block:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rutas-norte
  namespace: rutas-norte-pro
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - www.rutasnorte.example
        - api.rutasnorte.example
      secretName: rutasnorte-tls      # Secret in the SAME namespace as the Ingress
  rules:
    # ... the same rules as 04-04, unchanged

Three rules that break this if not met: the Secret must be in the same namespace as the Ingress (there are no cross-namespace references, so publishing the three environments requires the certificate in all three, and cert-manager can issue it in each); the hosts of the tls block must match those of the certificate, or the browser will give ERR_CERT_COMMON_NAME_INVALID; and several tls entries can be listed, each with its own Secret, with the controller choosing by SNI, the name the client announces when starting the TLS handshake.

  1. A self-signed certificate for development

For rutas-norte-dev we do not ask a public authority for certificates: the domain does not exist and the cluster is local. We generate our own.

# 2048-bit private key and a self-signed certificate valid for 365 days
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
  -keyout /tmp/rutasnorte-dev.key \
  -out    /tmp/rutasnorte-dev.crt \
  -subj "/C=ES/ST=Cantabria/L=Santander/O=Rutas Norte S.L./CN=www.rutasnorte.example" \
  -addext "subjectAltName=DNS:www.rutasnorte.example,DNS:api.rutasnorte.example"

Breaking it down: -x509 generates a certificate directly instead of a request (CSR); -nodes leaves the private key without a password, essential because nobody is going to type it in at startup; -newkey rsa:2048 creates key and certificate in one step; -subj supplies the holder details without prompting; and -addext subjectAltName adds the field that really matters.

About subjectAltName (SAN): browsers have spent years ignoring the CN and validating the SANs exclusively, so a certificate with no SAN is rejected however perfect the CN is —it is the number one mistake when generating certificates by hand—. Here we need two, one per domain, so that the same certificate serves the store and the API.

kubectl create secret tls rutasnorte-tls \
  --cert=/tmp/rutasnorte-dev.crt --key=/tmp/rutasnorte-dev.key -n rutas-norte-dev

# Check that the SANs are the expected ones
kubectl get secret rutasnorte-tls -n rutas-norte-dev \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -text \
  | grep -A1 "Subject Alternative Name"

curl -k -s -o /dev/null -w '%{http_code}\n' https://www.rutasnorte.example/
curl    -s https://www.rutasnorte.example/ 2>&1 | head -1
X509v3 Subject Alternative Name:
    DNS:www.rutasnorte.example, DNS:api.rutasnorte.example
200
curl: (60) SSL certificate problem: self-signed certificate

kubectl create secret tls sets the right type and the two keys with the right names.

With -k it works; without -k, it fails. That is correct and it is the lesson: the certificate encrypts just as well, but nobody can verify who issued it, and the browser will show "Your connection is not private" with NET::ERR_CERT_AUTHORITY_INVALID. A customer seeing that warning would not buy. That is why self-signed certificates are for development and internal testing, never for production.

  1. cert-manager: the problem it solves and how to install it

With certificates from a public authority, the manual process is: generate a key and a CSR, prove you control the domain, receive the certificate, create the Secret, and repeat every 90 days, which is how long Let's Encrypt ones last. The predictable outcome is the classic incident: on a Saturday night the certificate expires, the store stops working in the middle of a bank holiday, and nobody remembers how it was renewed because it was done by somebody who has since left.

cert-manager turns that into a reconciliation loop like any other: you declare that you want a certificate and it obtains it, stores it in a Secret and renews it before it expires, indefinitely.

# Installation with the CRDs included
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
kubectl get pods -n cert-manager
kubectl get crds | grep cert-manager.io
cert-manager-6d8b9c7f4d-jk29p             1/1   Running   0   60s
cert-manager-cainjector-5f9b7d8c6-w4xr2   1/1   Running   0   60s
cert-manager-webhook-7c4d9f8b5-mn6vt      1/1   Running   0   60s

certificaterequests.cert-manager.io    certificates.cert-manager.io
challenges.acme.cert-manager.io        clusterissuers.cert-manager.io
issuers.cert-manager.io                orders.acme.cert-manager.io

Three components: cert-manager is the controller that watches the Certificate objects and runs the issuance flow; cainjector injects CA bundles into webhooks and APIServices; and webhook validates and applies defaults to its own resources.

Six CRDs (06-06): cert-manager extends the Kubernetes API with its own types and a controller that reconciles them. It is the operator pattern of 06-07 in its purest form, and probably the most useful example you will come across.

  1. The cert-manager objects and the issuance flow

Object Scope Who creates it What it is
Issuer Namespace You A certificate source valid only in that namespace
ClusterIssuer Cluster You (administrator) A source usable from any namespace
Certificate Namespace You, or the Ingress via the annotation "I want a certificate for these domains, in this Secret"
CertificateRequest / Order / Challenge Namespace cert-manager The request with its CSR, the ACME order and each challenge to pass

The last three are internal: they are not written by hand, but they are where you read what is failing, following the chain CertificateCertificateRequestOrderChallenge.

sequenceDiagram
    participant U as You (or the Ingress)
    participant CM as cert-manager
    participant LE as Let's Encrypt (ACME)
    participant K8S as Kubernetes API

    U->>K8S: creates Certificate (domains + secretName)
    CM->>K8S: watches the Certificate
    CM->>CM: generates private key and CSR
    CM->>K8S: creates CertificateRequest
    CM->>K8S: creates Order
    CM->>LE: requests the order for the domains
    LE-->>CM: pending challenges (HTTP-01 or DNS-01)
    CM->>K8S: creates Challenge
    CM->>K8S: publishes the proof (temporary Ingress or TXT record)
    CM->>LE: "you can validate now"
    LE->>LE: checks the proof from the internet
    LE-->>CM: validated; here is your certificate
    CM->>K8S: creates/updates the kubernetes.io/tls Secret
    CM->>K8S: Certificate with Ready=True
    Note over CM: and schedules renewal at 60 days

  1. ACME with Let's Encrypt: HTTP-01 versus DNS-01

ACME (Automatic Certificate Management Environment) is the protocol that automates issuance. At its core is a single question: can you prove you control the domain?

The HTTP-01 challenge

Let's Encrypt asks you to publish a file with specific contents at http://<domain>/.well-known/acme-challenge/<token>; cert-manager creates a pod and a temporary Ingress to serve it, and deletes them when finished.

# k8s/base/clusterissuer-letsencrypt-http01.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-production
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]        # expiry and problem notifications
    privateKeySecretRef:
      name: letsencrypt-production-account   # ACME ACCOUNT key, not the certificate's
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx

An indispensable requirement: the domain must resolve to your Ingress and be reachable from the internet on port 80. Direct consequences: it does not work in minikube with .example domains resolved through /etc/hosts (which is why we use self-signed certificates in dev); it does not work for internal services with no public exposure; it cannot issue wildcard certificates; and if you redirect all HTTP to HTTPS you must exclude /.well-known/acme-challenge/ —modern controllers handle it on their own, but with custom rules it is a common failure.

The DNS-01 challenge

Let's Encrypt asks for a TXT record at _acme-challenge.<domain>. cert-manager creates it using the DNS provider's API and deletes it afterwards.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-dns-account
    solvers:
      - dns01:
          cloudflare:
            email: [email protected]
            apiTokenSecretRef:
              name: cloudflare-token       # Secret with the DNS API token
              key: token
        selector:
          dnsZones:
            - rutasnorte.example           # this solver only for this zone
HTTP-01 DNS-01
What is published A file under /.well-known/acme-challenge/ A TXT record
Requires a public port 80 Yes No
Works with internal services No Yes
Wildcard certificates No Yes, mandatory
Credentials needed None DNS API token
Speed Seconds Minutes (DNS propagation)
Risk Nothing special A token with permission to edit your DNS zone

Practical rule: HTTP-01 by default, and DNS-01 when you need wildcards or the service is not public. Rutas Norte would use HTTP-01 for its two domains; if tomorrow it wanted *.rutasnorte.example to give every agency a subdomain, it would have to move to DNS-01.

  1. Automatic issuance from the Ingress

You can create an explicit Certificate, useful when you want to control the duration, the key algorithm or the rotation:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: rutasnorte-tls
  namespace: rutas-norte-pro
spec:
  secretName: rutasnorte-tls          # the Secret that will be created
  duration: 2160h                     # 90 days
  renewBefore: 720h                   # renew 30 days before expiry
  privateKey:
    algorithm: ECDSA
    size: 256
    rotationPolicy: Always            # a new key on every renewal
  dnsNames: [www.rutasnorte.example, api.rutasnorte.example]
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer

But the convenient way is the annotation on the Ingress: cert-manager detects it and generates the Certificate for you from the tls: block.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rutas-norte
  namespace: rutas-norte-pro
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-production"
    # for a namespaced Issuer it would be: cert-manager.io/issuer
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - www.rutasnorte.example
        - api.rutasnorte.example
      secretName: rutasnorte-tls      # cert-manager will create this Secret
  rules:
    # ... the same rules as 04-04

All of Rutas Norte's certificate management comes down to one annotation: the domains come from tls.hosts and the destination from tls.secretName.

kubectl apply -f k8s/environments/pro/ingress-rutas-norte.yaml
kubectl get certificate,order,challenge -n rutas-norte-pro
certificate.cert-manager.io/rutasnorte-tls              False     rutasnorte-tls           12s
order.acme.cert-manager.io/rutasnorte-tls-1-2894732     pending                            11s
challenge.acme.cert-manager.io/rutasnorte-tls-1-...-0   pending   www.rutasnorte.example   10s

# ... a few seconds later
certificate.cert-manager.io/rutasnorte-tls              True      rutasnorte-tls           47s

READY: True and the Order/Challenge objects gone: the certificate is in the Secret and the Ingress controller is already serving it.

  1. The staging environment and the issuance limits

Let's Encrypt imposes strict limits: 50 certificates per registered domain per week, 5 validation failures per account, domain and hour, 5 exact duplicates per week (the same set of domains) and 100 names per certificate.

The duplicates one is the one that bites: five attempts with the same set of domains and you are blocked for a week; debugging a new configuration burns through them in ten minutes and there is no way to speed it up. That is why the staging environment exists, with the same limits multiplied many times over and certificates issued by a test CA that browsers do not recognise.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory   # the only difference
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-staging-account
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx

The correct procedure, with no exceptions: (1) configure the Ingress with cert-manager.io/cluster-issuer: letsencrypt-staging; (2) check kubectl get certificate until READY: True; (3) verify with curl -k that TLS works and that the issuer is (STAGING) Let's Encrypt; and (4) only then switch to production:

kubectl annotate ingress rutas-norte -n rutas-norte-pro \
  cert-manager.io/cluster-issuer=letsencrypt-production --overwrite
kubectl delete secret rutasnorte-tls -n rutas-norte-pro
kubectl get certificate -n rutas-norte-pro -w

That kubectl delete secret is necessary: without it, cert-manager sees a valid certificate and does not replace it until renewal is due.

  1. Automatic renewal and checking the status

cert-manager renews when there is less than renewBefore left until expiry (by default a third of the lifetime; with 90 days, at 60). The process is identical to issuance and the Secret is updated in place, without changing its name. Do the pods need restarting? No. The Ingress controller watches the Secret and reloads by itself. If a pod mounted the certificate as a volume, the kubelet propagates the change in about a minute, though the application would have to re-read it.

Checks

kubectl get certificate -A
kubectl describe certificate rutasnorte-tls -n rutas-norte-pro
Status:
  Conditions:
    Type:      Ready
    Status:    True
    Message:   Certificate is up to date and has not expired
  Not Before:  2026-07-30T09:14:23Z
  Not After:   2026-10-28T09:14:22Z
  Renewal Time: 2026-09-28T09:14:22Z
Events:
  Normal  Issued  21d  cert-manager-certificates-issuing  The certificate has been successfully issued

Not After and Renewal Time are the two fields to keep an eye on. And the certificate actually being served:

echo | openssl s_client -connect www.rutasnorte.example:443 \
  -servername www.rutasnorte.example 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates
issuer=C=US, O=Let's Encrypt, CN=R11
subject=CN=www.rutasnorte.example
notBefore=Jul 30 09:14:23 2026 GMT
notAfter=Oct 28 09:14:22 2026 GMT

The -servername is essential: without SNI, the controller would return its default certificate and you would be looking at the wrong one.

Diagnosis when READY is False

Follow the chain downwards to the Challenge, using kubectl describe on certificate, certificaterequest, order and challenge, and finally kubectl logs -n cert-manager -l app=cert-manager.

Message in the Challenge Cause
Waiting for HTTP-01 challenge propagation: wrong status code '404' The temporary Ingress is unreachable, or public DNS does not point at your cluster
connection refused / timeout Port 80 is not open from the internet
NXDOMAIN The domain does not exist in public DNS
too many certificates already issued Weekly limit reached. Wait or use staging
Issuer not found Confusion between Issuer and ClusterIssuer, or a misspelt name

That last one deserves attention: cert-manager.io/issuer looks for an Issuer in the Ingress's namespace; cert-manager.io/cluster-issuer looks for a global ClusterIssuer. Using the wrong annotation gives an error that looks like a naming problem and is really a type problem.

  1. Redirecting to HTTPS and HSTS

Publishing HTTPS is not enough: you have to prevent HTTP, because a customer who types the domain without a scheme will go over HTTP. In ingress-nginx the redirect is enabled by default as soon as the Ingress has a tls block, and it is controlled with the annotations ssl-redirect (HTTP → HTTPS) and force-ssl-redirect (even behind another proxy):

curl -sI http://www.rutasnorte.example/ | head -2
HTTP/1.1 308 Permanent Redirect
Location: https://www.rutasnorte.example/

But the redirect leaves a window open: the first request travels in the clear and can be intercepted. HSTS (HTTP Strict Transport Security) closes it: a header that orders the browser to use HTTPS for that domain for a period of time, without asking and without letting anybody click through the warning.

metadata:
  annotations:
    nginx.ingress.kubernetes.io/hsts: "true"
    nginx.ingress.kubernetes.io/hsts-max-age: "31536000"          # 1 year
    nginx.ingress.kubernetes.io/hsts-include-subdomains: "true"
    nginx.ingress.kubernetes.io/hsts-preload: "false"

Serious warnings about HSTS, because it is one of the few things in this lesson that can lock you out of your own domain:

  • It cannot be revoked quickly. Browsers remember the directive until max-age expires. If your certificate fails, users cannot get in, not even by accepting the risk.
  • includeSubDomains affects EVERY subdomain, including those that do not have HTTPS yet: an internal admin.rutasnorte.example over HTTP would become unreachable.
  • preload is practically irreversible: getting onto the browsers' preload list is easy, getting off it takes months. Start with a low max-age (300) and raise it progressively.

And this is the point to recall the opening warning: the HSTS policy, the accepted TLS versions (ssl-protocols: "TLSv1.2 TLSv1.3") and the cipher suites (ssl-ciphers), which are global settings in the controller's ConfigMap and not per-Ingress annotations, must be reviewed by a security professional before being applied in production.

  1. Beyond: mTLS and service meshes

What we have built protects the client ↔ Ingress leg; inside the cluster, traffic between the controller and web-store, or between bookings-api and bookings-postgres, is still in the clear. Two mechanisms go further. mTLS (mutual TLS): as well as the server, the client presents a certificate, so that bookings-api not only verifies who it is talking to but proves who it is; it is the foundation of cryptographic identity between services and it replaces trust based on IP addresses. And service meshes (Istio, Linkerd, Cilium Service Mesh), which inject a proxy alongside every pod —or build it into the kernel with eBPF— and establish mTLS automatically between all components, with short-lived certificates and without touching the code.

For Rutas Norte today it is overkill: five components in a cluster of its own, with the network policies of 04-06 restricting who can talk to whom. It would be reconsidered if the platform grew to dozens of services or if a regulatory requirement demanded end-to-end encryption. The full analysis is in 08-04.

Common Mistakes and Tips

  • Starting straight in Let's Encrypt production. Five failures and you are blocked for a week. Always staging first.
  • A certificate with no subjectAltName. Browsers ignore the CN. Without SANs, the certificate is useless.
  • A TLS Secret in another namespace. There are no cross-references: the Secret lives where the Ingress lives.
  • Confusing Issuer with ClusterIssuer in the annotation. The error says "not found" and it is really about the type.
  • Changing the issuer without deleting the Secret. cert-manager sees a valid certificate and replaces nothing.
  • Redirecting to HTTPS while blocking /.well-known/acme-challenge/. The HTTP-01 challenge stops working and renewal fails silently until it expires.
  • Enabling HSTS with preload from day one. Practically irreversible. Raise max-age in stages.
  • Trying HTTP-01 in minikube with /etc/hosts domains, or asking for a wildcard with HTTP-01: the first is unreachable from Let's Encrypt and the second is impossible by design (it requires DNS-01).
  • Tip: watch certmanager_certificate_expiration_timestamp_seconds in Prometheus (07-03) and alert with 21 days to spare. Automation fails too.
  • Tip: one Certificate per domain instead of one with many SANs limits the damage: a validation failure on one domain does not stop the others being renewed.
  • Tip: keep the ClusterIssuer in Git, but never the Secret with the ACME account private key or the DNS token. Apply what you learned in 03-02: Sealed Secrets, ESO or Vault.

Exercises

Exercise 1: HTTPS in development with a self-signed certificate

Use openssl to generate a certificate valid for www.rutasnorte.example and api.rutasnorte.example, create it as a TLS Secret in rutas-norte-dev, add it to the Ingress and check that HTTPS works with -k and fails without it. Explain exactly which guarantee is missing.

Exercise 2: Install cert-manager and create the two ClusterIssuers

Install cert-manager, create letsencrypt-staging and letsencrypt-production with the HTTP-01 challenge, and annotate the production Ingress with the staging one. Follow the chain CertificateOrderChallenge and explain why the challenge will not complete in minikube.

Exercise 3: Diagnose a certificate that will not issue

A Certificate has been at READY: False for 20 minutes. Design the full diagnostic procedure, stating which command to use at each level and what message you would expect for three different causes: a domain that does not resolve from the internet, a closed port 80 and the issuance limit being reached.

Solutions

Exercise 1

openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
  -keyout /tmp/dev.key -out /tmp/dev.crt \
  -subj "/C=ES/O=Rutas Norte S.L./CN=www.rutasnorte.example" \
  -addext "subjectAltName=DNS:www.rutasnorte.example,DNS:api.rutasnorte.example"

kubectl create secret tls rutasnorte-tls \
  --cert=/tmp/dev.crt --key=/tmp/dev.key -n rutas-norte-dev

kubectl patch ingress rutas-norte -n rutas-norte-dev --type=merge -p '
spec:
  tls:
    - hosts: ["www.rutasnorte.example","api.rutasnorte.example"]
      secretName: rutasnorte-tls'

curl -k -s -o /dev/null -w 'with -k: %{http_code}\n' https://www.rutasnorte.example/
curl    -s -o /dev/null https://www.rutasnorte.example/ || echo "without -k: failed (expected)"

What is missing is authentication, not encryption. The channel is just as encrypted, but no trusted party vouches for that certificate belonging to Rutas Norte: an attacker who hijacked DNS could present their own self-signed certificate and the customer would not notice the difference. The chain of trust is what a public CA contributes.

Exercise 2

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
kubectl wait --for=condition=Available deploy --all -n cert-manager --timeout=180s
kubectl apply -f k8s/base/clusterissuer-letsencrypt-staging.yaml
kubectl apply -f k8s/base/clusterissuer-letsencrypt-production.yaml
kubectl get clusterissuer      # both must appear with READY True

kubectl annotate ingress rutas-norte -n rutas-norte-pro \
  cert-manager.io/cluster-issuer=letsencrypt-staging --overwrite
kubectl describe challenge -n rutas-norte-pro | tail -12
Reason: Waiting for HTTP-01 challenge propagation: failed to perform self check
        GET request: Get "http://www.rutasnorte.example/.well-known/acme-challenge/xY3...":
        dial tcp: lookup www.rutasnorte.example: no such host

In minikube the domain only exists in your /etc/hosts. Let's Encrypt's servers cannot resolve it and cannot reach your cluster, so HTTP-01 cannot complete by design. It is exactly why self-signed certificates are used in development.

Exercise 3

NS=rutas-norte-pro
kubectl describe certificate rutasnorte-tls -n $NS | sed -n '/Status/,$p'  # level 1
kubectl describe certificaterequest -n $NS | grep -A3 "Conditions"         # level 2
kubectl describe order -n $NS | grep -A5 "Status"                          # level 3
kubectl describe challenge -n $NS | grep -A5 "Reason"       # level 4: the useful message
kubectl logs -n cert-manager -l app=cert-manager --tail=80 | grep -i error  # level 5
Cause Expected message Where it appears
Domain that does not resolve dial tcp: lookup www.rutasnorte.example: no such host Challenge
Closed port 80 connection refused or context deadline exceeded after the self check Challenge
Limit reached 429 urn:ietf:params:acme:error:rateLimited: too many certificates already issued Order and cert-manager logs

For the first two you fix the infrastructure (public DNS, firewall). For the third there is no technical fix: you wait for the week to pass or you work in staging.

Conclusion

Rutas Norte now encrypts its traffic. You know why HTTPS is not optional on a platform handling ID numbers, phone numbers and payments, and that TLS provides two different things: encryption and identity. You know the three termination models —at the Ingress, re-encryption and passthrough— and why terminating at the Ingress is the right choice here. You have mastered the basic piece, the Secret of type kubernetes.io/tls with its tls.crt and tls.key keys, and the three rules that govern it: same namespace as the Ingress, a match between the hosts of the tls block and the certificate's SANs, and selection by SNI when there are several.

You have generated a self-signed certificate with openssl for development, with the decisive detail of the subjectAltName that browsers validate while ignoring the CN, and you have checked in your own terminal the difference between "encrypted" and "verifiable": it works with -k, it fails without it, and that difference is the reason self-signed certificates never reach production.

Then you automated the problem with cert-manager, which is the operator pattern applied to certificates: six CRDs, a controller and a reconciliation loop. You know what an Issuer is and how it differs from a ClusterIssuer, and how cert-manager chains CertificateCertificateRequestOrderChallenge underneath, which is also the order in which any failure is diagnosed. You understand ACME and its two challenges: HTTP-01, simple but requiring reachability from the internet on port 80 and unable to issue wildcards, and DNS-01, indispensable for wildcards and for internal services in exchange for trusting a DNS API token. And you have reduced all the management to one annotation on the Ingress, with the non-negotiable discipline of always starting in staging so as not to burn Let's Encrypt's five weekly attempts.

You know how to check the status with kubectl get certificate and openssl s_client -servername, that renewal happens 30 days before expiry without restarting anything, and how to shut the door on HTTP with the redirect and with HSTS, weighing its risks properly because it cannot be revoked at will. And you know what is left out: traffic inside the cluster is still in the clear, and that means mTLS and service meshes, the subject of 08-04.

One big hole remains, the same one we have been dragging along since module 3: any pod in the cluster can connect to bookings-postgres:5432. A compromised pod, an image with a malicious dependency or a simple deployment mistake in rutas-norte-dev has direct access to the database holding every customer's personal data. In 04-06, the last lesson of the module, we will build the platform's isolation model: deny everything by default, open DNS —the classic mistake that takes down the whole cluster—, and authorise, one by one, only the conversations Rutas Norte needs.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved