Phase 4 · ORCHESTRATION & IAC
Project 3, Day 4: Helm, ingress & TLS
By the end of today
- Package linkstash's manifests into a versioned Helm chart with values
- Expose the app on a hostname through a Traefik Ingress on k3s
- Issue automatic TLS with cert-manager and a Let's Encrypt ClusterIssuer
Package it as a chart, then serve it over HTTPS
Yesterday you applied raw manifests — a Postgres Deployment, the linkstash Deployment, a Service and a ConfigMap — with kubectl apply -f. It works, but it is a pile of loose YAML: nothing versions it, nothing parameterises the image tag or the hostname, and reaching the app still means a port-forward. Today you fix both. You package linkstash into a Helm chart and you expose it on a real hostname over HTTPS.
The packaging is the Helm lifecycle from Day 75, now on your own app. A chart is a directory — chart/linkstash/ — holding Chart.yaml (name and version), values.yaml (the knobs: image.tag, replicaCount, ingress.host), and templates/ (your Day-83 manifests with the changing parts pulled out into {{ .Values… }} placeholders). helm install linkstash ./chart/linkstash renders the templates against the values and applies the result as one tracked release you can upgrade and rollback. The Secret and Postgres stay as they are; the chart owns only the app tier — Deployment, Service, ConfigMap and Ingress.
Exposing it is where k3s earns its keep. Unlike the plain kind cluster of Day 74 — where you installed ingress-nginx by hand — k3s ships Traefik as its built-in ingress controller, already running in kube-system, already bound to the node’s ports 80 and 443 by k3s’s klipper service load balancer. You do not install a controller; you just create an Ingress with ingressClassName: traefik and a host:, and Traefik routes that hostname to your Service. Point a DNS A record for links.example.com at the instance’s Elastic IP and the app answers on the public internet.
Plain HTTP is not enough. The last mile is TLS, and you automate it with cert-manager (a Helm install) plus a ClusterIssuer. cert-manager watches your Ingress, and when it sees a cert-manager.io/cluster-issuer annotation and a tls: block it requests a certificate from a Certificate Authority, proves you control the domain with an HTTP-01 challenge, drops the signed cert into a Secret that Traefik serves — then renews it before it expires, forever.
Real world: Let’s Encrypt is a nonprofit CA that has issued billions of free certificates and made HTTPS the default across the web. Its certs are deliberately short-lived — 90 days — so a leaked key can’t be abused for long. Renewing that by hand every quarter would be a chore you’d forget; cert-manager is the tireless clerk that walks back to the notary before each expiry and returns with a fresh stamp, so your padlock never lapses.
Two honest caveats. First, a public CA must reach your domain to verify it, so the Let’s Encrypt path needs a real DNS A record at your Elastic IP with port 80 open — which the k3s-sg security group already allows. While you test, use the staging issuer (its certs are untrusted but its rate limits are generous), then switch to production. Second, if you own no domain, skip ACME entirely: a self-signed ClusterIssuer mints a cert instantly with no DNS, and curl -k accepts it. Either way, today ends with linkstash answering on https://.
Hands-On Lab
Budget about 35 minutes. You’re driving the k3s cluster from Day 82 through the kubeconfig you rewrote to the Elastic IP, with the Day-83 Postgres and linkstash-db Secret still running. Helm 3.18+ and a matching kubectl are on your laptop. Release timestamps, pod suffixes, cluster IPs and the certificate’s issuance time are unique to each run — yours will differ, and your Elastic IP is not 203.0.113.24 (a documentation placeholder).
What this costs: roughly the same ~$19/month run-rate as yesterday — the
t3.small(~$0.0208/hr) and the attached Elastic IP ($0.005/hr) keep billing 24/7, and nothing you add today costs a cloud rupee more. Traefik is already inside k3s, cert-manager runs as three small pods on the node you’re already paying for, and Let’s Encrypt certificates are free. On a free-tiert2.microthe compute is ₹0 for the first year. The bill only returns to ₹0 tomorrow, when Day 85 runsterraform destroy— until then the instance is live, so if you stop for the day, that teardown is one command away.
# 1. Point at the k3s cluster (kubeconfig from Day 82) and confirm the node and
# yesterday's Postgres are healthy before you package anything.
export KUBECONFIG=~/linkstash/deploy/capstone/k3s.yaml
kubectl get nodes
kubectl get pods -l app=postgres
helm version
# Output (node name, ages, pod suffix and Helm patch differ):
# NAME STATUS ROLES AGE VERSION
# linkstash-k3s Ready control-plane,master 1d v1.31.5+k3s1
# NAME READY STATUS RESTARTS AGE
# postgres-7d9c8b5f4-mn2kq 1/1 Running 0 1d
# version.BuildInfo{Version:"v3.18.4", GitCommit:"…", GitTreeState:"clean", GoVersion:"go1.24.5"}
# 2. Helm can't adopt resources it didn't create, so delete the RAW linkstash app
# objects from Day 83 (leave Postgres and the linkstash-db Secret untouched).
kubectl delete deployment linkstash service linkstash configmap linkstash-config
# Output:
# deployment.apps "linkstash" deleted
# service "linkstash" deleted
# configmap "linkstash-config" deleted
# 3. chart/linkstash/Chart.yaml and values.yaml — the package metadata and its knobs.
# --- Chart.yaml ---
apiVersion: v2
name: linkstash
description: FastAPI URL shortener
type: application
version: 0.1.0 # chart version — bump when the templates change
appVersion: "1.0.0" # informational: the app version this chart ships
# --- values.yaml ---
replicaCount: 1
image:
repository: ghcr.io/pushkar/linkstash
tag: "v1.0.0"
pullPolicy: IfNotPresent
service:
port: 80
targetPort: 8000
config:
APP_ENV: production
LOG_LEVEL: info
db:
secretName: linkstash-db # created Day 83; holds DATABASE_URL
ingress:
enabled: true
className: traefik
host: links.example.com
tls:
enabled: false
issuer: letsencrypt-staging
# 4. chart/linkstash/templates/deployment.yaml — the Day-83 Deployment, templated.
# envFrom pulls the ConfigMap; DATABASE_URL comes from the Day-83 Secret.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: linkstash
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.targetPort }}
envFrom:
- configMapRef:
name: {{ .Release.Name }}-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.db.secretName }}
key: DATABASE_URL
readinessProbe:
httpGet:
path: /healthz
port: {{ .Values.service.targetPort }}
initialDelaySeconds: 3
periodSeconds: 5
# 5. The other three templates. The Ingress is Traefik-classed and turns TLS on
# only when ingress.tls.enabled — that's when cert-manager reads the annotation.
# --- templates/configmap.yaml ---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
labels:
app: {{ .Release.Name }}
data:
APP_ENV: {{ .Values.config.APP_ENV | quote }}
LOG_LEVEL: {{ .Values.config.LOG_LEVEL | quote }}
# --- templates/service.yaml ---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
selector:
app: {{ .Release.Name }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
# --- templates/ingress.yaml ---
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
{{- if .Values.ingress.tls.enabled }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.issuer }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}
port:
number: {{ .Values.service.port }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Release.Name }}-tls
{{- end }}
{{- end }}
# 6. Lint the chart, then render it locally to confirm the image tag and host land
# where you expect — no cluster contact yet.
helm lint ./chart/linkstash
helm template linkstash ./chart/linkstash | grep -E 'image:|host:|ingressClassName:'
# Output (lint passes; the rendered values match values.yaml):
# ==> Linting ./chart/linkstash
# 1 chart(s) linted, 0 chart(s) failed
# image: "ghcr.io/pushkar/linkstash:v1.0.0"
# ingressClassName: traefik
# - host: links.example.com
# 7. Install the chart as the release "linkstash" and confirm the app tier is up.
helm install linkstash ./chart/linkstash
helm list
kubectl get pods,svc,ingress -l app=linkstash
# Output (revision 1; suffixes, IPs and ages differ; the Ingress ADDRESS is the node IP):
# NAME: linkstash
# LAST DEPLOYED: Sun Jul 12 11:04:22 2026
# STATUS: deployed
# REVISION: 1
# NAME NAMESPACE REVISION STATUS CHART APP VERSION
# linkstash default 1 deployed linkstash-0.1.0 1.0.0
# NAME READY STATUS RESTARTS AGE
# pod/linkstash-6c9f8b7d4-r7t2v 1/1 Running 0 20s
# NAME TYPE CLUSTER-IP PORT(S) AGE
# service/linkstash ClusterIP 10.43.128.77 80/TCP 20s
# NAME CLASS HOSTS ADDRESS PORTS AGE
# ingress.networking.k8s.io/linkstash traefik links.example.com 203.0.113.24 80 20s
# 8. Prove Traefik routes the host to linkstash — no DNS needed yet, just spoof the
# Host header against the Elastic IP. /healthz answers 200 through the Ingress.
curl -s -H 'Host: links.example.com' http://203.0.113.24/healthz
# Output (the app, reached over plain HTTP through Traefik on port 80):
# {"status":"ok"}
# 9. Install cert-manager via Helm (its CRDs included) so TLS becomes automatic.
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--version v1.18.2 \
--set crds.enabled=true
kubectl get pods -n cert-manager
# Output (three pods; your version and suffixes differ — any recent 1.x is fine):
# NAME READY STATUS RESTARTS AGE
# cert-manager-7f9b8c6d5-8x2kp 1/1 Running 0 40s
# cert-manager-cainjector-5c7d9f8b4-lm3qr 1/1 Running 0 40s
# cert-manager-webhook-6b8f5c9d4-p9w2t 1/1 Running 0 40s
# 10. clusterissuer.yaml — staging + production Let's Encrypt issuers (HTTP-01 via
# Traefik) AND a self-signed fallback for learners with no domain.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@example.com # your real email — Let's Encrypt mails expiry notices
privateKeySecretRef:
name: letsencrypt-staging-key
solvers:
- http01:
ingress:
ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory # trusted certs; strict rate limits
email: you@example.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned # no domain? use this — instant cert, no ACME, curl -k
spec:
selfSigned: {}
This staging path needs a real domain. links.example.com must have a DNS A record pointing at your Elastic IP (203.0.113.24) and port 80 reachable, because Let’s Encrypt fetches http://links.example.com/.well-known/acme-challenge/… to verify you. If you own no domain, swap --set ingress.tls.issuer=selfsigned in the next step — cert-manager issues a cert in seconds with no DNS and no ACME.
# 11. Apply the issuers, then upgrade the release to turn TLS on. cert-manager
# requests the cert and writes it to the linkstash-tls Secret when it's Ready.
kubectl apply -f clusterissuer.yaml
helm upgrade linkstash ./chart/linkstash \
--set ingress.tls.enabled=true \
--set ingress.tls.issuer=letsencrypt-staging
kubectl get certificate
# Output (READY flips to True once the HTTP-01 challenge passes — needs the A record):
# clusterissuer.cert-manager.io/letsencrypt-staging created
# clusterissuer.cert-manager.io/letsencrypt-prod created
# clusterissuer.cert-manager.io/selfsigned created
# Release "linkstash" has been upgraded. Happy Helming!
# NAME READY SECRET AGE
# linkstash-tls True linkstash-tls 55s
# 12. Verify HTTPS end to end. --resolve maps the host to the Elastic IP so this
# works before DNS propagates; -k accepts the untrusted STAGING cert.
curl -kv --resolve links.example.com:443:203.0.113.24 \
https://links.example.com/healthz 2>&1 | grep -E 'subject:|issuer:|HTTP|status'
# Output (Traefik served the cert-manager cert; the app answered over TLS):
# * subject: CN=links.example.com
# * issuer: C=US; O=(STAGING) Let's Encrypt; CN=(STAGING) Wannabe Watercress R11
# > GET /healthz HTTP/2
# < HTTP/2 200
# {"status":"ok"}
Read the last three steps back: Traefik terminated TLS on 443, using a certificate cert-manager fetched from Let’s Encrypt and stored in linkstash-tls, and forwarded the request to your Service and Pod — the app answered 200 over HTTPS. Because it’s the staging issuer, the cert is real but untrusted, which is why curl needed -k. Flip --set ingress.tls.issuer=letsencrypt-prod once you’re confident and you get a browser-trusted padlock — but leave that swap for when the DNS is settled, so you don’t spend a production rate-limit slot on a test. Tomorrow you run the full shorten-and-redirect flow through this HTTPS endpoint, then tear the whole stack down.
Common Errors & Fixes
These three catch people the first time they Helm-package an app they already applied by hand and then chase a TLS cert on a real cluster. Read the messages slowly — a stuck Certificate and a 404 from Traefik point at completely different mistakes.
Common error: Running
helm installwithout first deleting the raw objects Day 83 applied, so Helm refuses to overwrite resources it doesn’t own:Error: INSTALLATION FAILED: Unable to continue with install: ConfigMap "linkstash-config" in namespace "default" exists and cannot be imported into the current release: invalid ownership metadata; label validation error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"Why: Helm only manages resources it created, marked with
app.kubernetes.io/managed-by: Helmand release annotations. An object applied earlier bykubectl apply -fcarries none of that, so Helm won’t silently adopt it — it stops rather than clobber something another tool owns.Fix: Delete the raw app objects first (lab step 2:
kubectl delete deployment/service/configmap linkstash…), leaving Postgres and the Secret alone, thenhelm install. On Helm 3.17+ you could instead pass--take-ownership, but a clean delete-then-install is clearer here.How you’d spot it in prod: “invalid ownership metadata … must be set to Helm” always means a resource of that name already exists outside the release — someone applied YAML by hand, or a previous tool created it. Reconcile ownership before you install, don’t force past it blindly.
Common error: Turning on the Let’s Encrypt issuer before the domain’s DNS A record points at the Elastic IP (or with port 80 closed), so the certificate never issues:
$ kubectl describe certificate linkstash-tls ... Status: Conditions: Reason: InProgress Message: Issuing certificate as Secret does not exist Events: Warning Failed ... propagation: wait for http-01 challenge propagation: failed to perform self check GET request 'http://links.example.com/.well-known/acme-challenge/…': dial tcp: connection refusedWhy: The HTTP-01 challenge requires Let’s Encrypt (and cert-manager’s own self-check) to fetch a token over
http://links.example.comon port 80. With no A record at the Elastic IP, or thek3s-sgblocking 80, that fetch can’t land, the challenge never validates, and theCertificatesitsREADY=Falseforever.Fix: Create the DNS A record for the host at your Elastic IP and confirm
80is open ink3s-sg, then delete the failed challenge/certificate so cert-manager retries. No domain at all? Switch the issuer toselfsigned— it needs neither DNS nor port 80.How you’d spot it in prod: A
CertificatestuckREADY=Falsewhose events mention an HTTP-01 self-check orconnection refusedis almost never cert-manager’s fault — it’s DNS or a firewall. Verify the record resolves to the right IP and the port is reachable from the internet first.
Common error: Carrying the Day-74 habit of
ingressClassName: nginxonto k3s (which only ships Traefik), or curling the node IP with no Host header — either way Traefik returns a bare 404:$ curl -s http://203.0.113.24/healthz 404 page not foundWhy: k3s has no
nginxIngressClass, so an Ingress asking for one is never programmed by any controller and matches nothing. And Traefik routes by theHostheader, so a request whose host doesn’t match any Ingress rule falls through to Traefik’s default 404 — the app is fine; nothing routed to it.Fix: Use
ingressClassName: traefik(the chart’s default) and always send the matching host —curl -H 'Host: links.example.com' …or--resolve links.example.com:443:<EIP>. Confirm the class exists withkubectl get ingressclass.How you’d spot it in prod: A
404 page not found(Traefik’s wording, not your app’s) while the Pods are healthy means routing, not the app: check the Ingress class matches an installed controller and that the request’s Host header matches a rule.
Helm & TLS Interview Questions
Packaging an app as a chart and getting automatic HTTPS onto it are exactly the “can you actually ship it?” questions a Phase-4 screen asks once Kubernetes is on your CV — a calm answer that names the release, the ClusterIssuer and the HTTP-01 challenge beats reciting flags. The four questions and answers render right after this note; cover each, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day.
Go Deeper
Optional extras if you have ~30 more minutes:
- 5 min — Run
helm get manifest linkstashto see the exact YAML Helm applied, andhelm history linkstashto see the two revisions (install, then the TLS upgrade). - 10 min — Go trusted:
helm upgrade linkstash ./chart/linkstash --set ingress.tls.enabled=true --set ingress.tls.issuer=letsencrypt-prod, delete the oldlinkstash-tlsSecret so cert-manager re-issues, and watchcurl(without-k) show a browser-trusted certificate. - 10 min — Skim cert-manager’s ingress-shim and HTTP-01 docs to see how one annotation becomes a
Certificate, a temporary solver route, and a renewed Secret. - 5 min — Read how k3s bundles Traefik as a HelmChart in kube-system and how a
HelmChartConfigcustomises it — the managed-addon pattern that saved you an install today.
Why package these manifests as a Helm chart instead of just applying the raw YAML? Both
Raw YAML works for one environment, but it has no version, no release history, and every environment-specific value — the image tag, the hostname, the replica count — is hard-coded, so you copy-paste and drift. A chart pulls those into values.yaml, renders the templates against them, and installs the result as a named release Helm tracks. That buys me three things: one command deploys the whole app tier, helm upgrade with a new tag is a clean rollout, and helm rollback restores the previous revision if it breaks. For a service I redeploy across dev and prod, that repeatability and rollback is worth the templating cost.
How does cert-manager get a TLS certificate from Let's Encrypt? Both
You install cert-manager and create a ClusterIssuer pointing at Let's Encrypt's ACME endpoint. Then you annotate the Ingress with cert-manager.io/cluster-issuer and add a tls block naming a secret. cert-manager's ingress-shim sees that, creates a Certificate object, and starts the ACME flow: it asks Let's Encrypt for the cert, gets an HTTP-01 challenge, serves a token under /.well-known/acme-challenge/ through a temporary route, and Let's Encrypt fetches it to prove I control the domain. On success cert-manager writes the signed cert into the Secret, Traefik serves it, and cert-manager renews it automatically before the 90-day expiry. I never touch a certificate file by hand.
Why start with the Let's Encrypt staging issuer instead of production? Service
Production Let's Encrypt has strict rate limits — famously about five duplicate certificates per domain per week — and while I'm debugging DNS, firewall rules and annotations I can easily burn through those and get locked out for days. The staging environment has far higher limits and behaves identically, so I point the ClusterIssuer at the staging ACME endpoint first and iterate freely. The only difference is that staging certs are signed by an untrusted root, so browsers warn and curl needs -k — but the whole plumbing is proven. Once a staging cert issues cleanly, I switch the issuer to production and get a real, trusted certificate on the first try.
k3s ships Traefik as its ingress controller — how does that differ from installing ingress-nginx yourself? Product
On a plain cluster you install an ingress controller yourself — on Day 74 that was ingress-nginx via a manifest, plus wiring the node's ports. k3s bundles Traefik out of the box: it's deployed as a managed HelmChart in kube-system and exposed on the node's 80 and 443 by k3s's klipper service load balancer, so an Ingress with ingressClassName: traefik just works with nothing to install. The tradeoff is control — the annotations and config differ from nginx, and a managed platform like EKS gives you neither by default, so there you'd install a controller yourself. For a single-node k3s box, built-in Traefik is the least-effort correct choice.
Mark Day 84 complete
Tomorrow is the finale — an end-to-end smoke test over HTTPS, the README and architecture writeup, a push to GitHub, then terraform destroy back to ₹0.
Stuck on today’s lab? Ask in Mission 90 Q&A