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
- Why HTTPS is mandatory in Rutas Norte
- Where TLS terminates in a cluster
- The
kubernetes.io/tlsSecret and the Ingresstls:block - A self-signed certificate for development
- cert-manager: the problem it solves and how to install it
- The cert-manager objects and the issuance flow
- ACME with Let's Encrypt:
HTTP-01versusDNS-01 - Automatic issuance from the Ingress
- The staging environment and the issuance limits
- Automatic renewal and checking the status
- Redirecting to HTTPS and HSTS
- Beyond: mTLS and service meshes
- 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.
- 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).
- The
kubernetes.io/tls Secret and the Ingress tls: block
kubernetes.io/tls Secret and the Ingress tls: blockA 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, unchangedThree 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.
- 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 -1X509v3 Subject Alternative Name:
DNS:www.rutasnorte.example, DNS:api.rutasnorte.example
200
curl: (60) SSL certificate problem: self-signed certificatekubectl 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.
- 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.iocert-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.ioThree 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.
- 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 Certificate → CertificateRequest → Order → Challenge.
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
- ACME with Let's Encrypt:
HTTP-01 versus DNS-01
HTTP-01 versus DNS-01ACME (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: nginxAn 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 zoneHTTP-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.
- 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: ClusterIssuerBut 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-04All 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-procertificate.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 47sREADY: True and the Order/Challenge objects gone: the certificate is in the Secret and the Ingress controller is already serving it.
- 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: nginxThe 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 -wThat kubectl delete secret is necessary: without it, cert-manager sees a valid certificate and does not replace it until renewal is due.
- 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
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 issuedNot 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 -datesissuer=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 GMTThe -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.
- 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):
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-ageexpires. If your certificate fails, users cannot get in, not even by accepting the risk. includeSubDomainsaffects EVERY subdomain, including those that do not have HTTPS yet: an internaladmin.rutasnorte.exampleover HTTP would become unreachable.preloadis practically irreversible: getting onto the browsers' preload list is easy, getting off it takes months. Start with a lowmax-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.
- 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 theCN. 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
IssuerwithClusterIssuerin 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/. TheHTTP-01challenge stops working and renewal fails silently until it expires. - Enabling HSTS with
preloadfrom day one. Practically irreversible. Raisemax-agein stages. - Trying
HTTP-01in minikube with/etc/hostsdomains, or asking for a wildcard withHTTP-01: the first is unreachable from Let's Encrypt and the second is impossible by design (it requiresDNS-01). - Tip: watch
certmanager_certificate_expiration_timestamp_secondsin Prometheus (07-03) and alert with 21 days to spare. Automation fails too. - Tip: one
Certificateper 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
ClusterIssuerin 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 Certificate → Order → Challenge 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 -12Reason: 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 hostIn 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 Certificate → CertificateRequest → Order → Challenge 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
