LEVEL 1
Raw Kubernetes YAML
↓
LEVEL 2
Helm packages/templates the Kubernetes YAML
↓
LEVEL 3
Argo CD watches Git and deploys Helm to Kubernetes
↓
Production-style GitOps
I would structure the project like this.
JumpToTech Production Kubernetes + Helm + Argo CD Lab
Architecture
Developer
│
│ git push
▼
GitHub
│
│ watched by
▼
Argo CD
│
│ renders Helm chart
▼
Helm
│
│ produces Kubernetes manifests
▼
EKS Cluster
│
└── Namespace: jumptotech-prod
│
├── ConfigMap
├── Secret
├── ServiceAccount
│
├── Deployment
│ │
│ └── ReplicaSet
│ │
│ ├── Pod
│ ├── Pod
│ └── Pod
│
├── Service
│
├── PDB
│
├── HPA
│
└── Ingress
│
▼
AWS Load Balancer
│
▼
Internet
The application image will already be stored in ECR.
Part 1 — Repository structure
Have students create:
jumptotech-production/
│
├── app/
│ ├── index.html
│ └── Dockerfile
│
├── kubernetes/
│ ├── namespace.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ ├── serviceaccount.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── pdb.yaml
│ ├── hpa.yaml
│ └── ingress.yaml
│
├── helm/
│ └── jumptotech/
│ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/
│ ├── _helpers.tpl
│ ├── configmap.yaml
│ ├── serviceaccount.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── pdb.yaml
│ ├── hpa.yaml
│ └── ingress.yaml
│
└── argocd/
└── application.yaml
This lets you teach the same application three times:
Raw YAML → Helm → Argo CD
That repetition is valuable because students see exactly what each new tool solves.
PART 2 — Raw Kubernetes
1. Namespace
kubernetes/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: jumptotech-prod
labels:
environment: production
managed-by: jumptotech
Apply:
kubectl apply -f kubernetes/namespace.yaml
Check:
kubectl get ns
Explain:
Namespace provides a logical boundary for namespaced Kubernetes resources.
Architecture:
EKS Cluster
│
├── default
├── kube-system
└── jumptotech-prod
└── our application
2. ConfigMap
kubernetes/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: jumptotech-config
namespace: jumptotech-prod
data:
APP_NAME: "JumpToTech"
APP_ENV: "production"
LOG_LEVEL: "info"
Explain:
ConfigMap
↓
non-sensitive configuration
APP_ENV
LOG_LEVEL
API_URL
application settings
Apply:
kubectl apply -f kubernetes/configmap.yaml
Check:
kubectl get configmap -n jumptotech-prod
kubectl describe configmap jumptotech-config \
-n jumptotech-prod
3. Secret
For a classroom demonstration:
kubernetes/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: jumptotech-secret
namespace: jumptotech-prod
type: Opaque
stringData:
DB_USERNAME: "admin"
DB_PASSWORD: "student-demo-password"
Apply:
kubectl apply -f kubernetes/secret.yaml
Explain the difference:
ConfigMap
↓
non-sensitive data
Secret
↓
sensitive data
But tell students:
Do not commit real production passwords to Git.
Even:
data:
password: cGFzc3dvcmQ=
doesn't make the password secure merely because it is Base64.
For a real production GitOps architecture you would normally integrate a secrets-management solution rather than committing plaintext credentials.
4. ServiceAccount
kubernetes/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: jumptotech-sa
namespace: jumptotech-prod
Explain:
Person
↓
User identity
Workload
↓
ServiceAccount
ServiceAccount answers:
"Who is this workload?"
It does not automatically mean:
"This workload can do everything."
Authorization is separate, such as RBAC.
5. Deployment
This is the most important file.
kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: jumptotech-web
namespace: jumptotech-prod
labels:
app: jumptotech-web
spec:
replicas: 3
selector:
matchLabels:
app: jumptotech-web
template:
metadata:
labels:
app: jumptotech-web
spec:
serviceAccountName: jumptotech-sa
containers:
- name: web
image: YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/jumptotech-web:v1
ports:
- name: http
containerPort: 80
protocol: TCP
envFrom:
- configMapRef:
name: jumptotech-config
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: jumptotech-secret
key: DB_USERNAME
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: jumptotech-secret
key: DB_PASSWORD
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
Spend significant class time on this file.
6. Understand Deployment
Deployment
replicas: 3
│
▼
ReplicaSet
│
┌───┼────┐
▼ ▼ ▼
Pod Pod Pod
The Deployment doesn't directly "run three containers."
It declares the desired state.
The Deployment controller manages ReplicaSets, and the ReplicaSet maintains the desired number of Pods.
7. Requests and Limits
Inside the Deployment:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
Teach:
REQUEST
↓
Scheduler uses it when deciding
whether a Node has capacity
LIMIT
↓
maximum resource constraint
for the container
CPU:
1000m = 1 CPU
500m = 0.5 CPU
100m = 0.1 CPU
Then connect it to scheduling:
New Pod
request:
CPU 100m
RAM 128Mi
│
▼
Scheduler
│
├── Node A → enough capacity
├── Node B → not enough
└── Node C → enough
│
▼
Node C
│
▼
kubelet
│
▼
Container
8. Readiness Probe
readinessProbe:
httpGet:
path: /
port: http
Question:
Is this Pod ready to receive traffic?
Pod Running
│
▼
Readiness
│
┌──┴───┐
YES NO
│ │
▼ ▼
Ready Not Ready
for
traffic
Important:
Readiness failure doesn't normally restart the container.
9. Liveness Probe
livenessProbe:
httpGet:
path: /
port: http
Question:
Is this container/application still healthy enough to keep running?
Repeated liveness failures can cause kubelet to restart the container.
Liveness fails
↓
failure threshold reached
↓
kubelet
↓
restart container
10. Apply Deployment
kubectl apply -f kubernetes/serviceaccount.yaml
kubectl apply -f kubernetes/deployment.yaml
Check:
kubectl get deployment -n jumptotech-prod
kubectl get rs -n jumptotech-prod
kubectl get pods -n jumptotech-prod
kubectl get pods -n jumptotech-prod -o wide
Then:
kubectl describe pod POD_NAME -n jumptotech-prod
Students should locate:
Image
Ports
Environment
Requests
Limits
Readiness
Liveness
ServiceAccount
Events
11. Service
kubernetes/service.yaml
apiVersion: v1
kind: Service
metadata:
name: jumptotech-service
namespace: jumptotech-prod
spec:
type: ClusterIP
selector:
app: jumptotech-web
ports:
- name: http
protocol: TCP
port: 80
targetPort: http
Explain:
Service
selector:
app=jumptotech-web
│
▼
Find matching Pod labels
│
┌─────┼─────┐
▼ ▼ ▼
Pod Pod Pod
Apply:
kubectl apply -f kubernetes/service.yaml
Check:
kubectl get svc -n jumptotech-prod
Then:
kubectl get endpointslice \
-n jumptotech-prod \
-l kubernetes.io/service-name=jumptotech-service
12. Test before ALB
kubectl port-forward \
service/jumptotech-service \
8080:80 \
-n jumptotech-prod
Browser:
http://localhost:8080
This proves:
Deployment
↓
Pods
↓
Service
↓
Application
works before adding another layer.
13. PDB
kubernetes/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: jumptotech-pdb
namespace: jumptotech-prod
spec:
minAvailable: 2
selector:
matchLabels:
app: jumptotech-web
Architecture:
3 replicas
Pod A
Pod B
Pod C
PDB:
minAvailable: 2
This helps preserve availability during supported voluntary disruptions, such as certain maintenance/eviction operations.
It does not prevent every crash or infrastructure failure.
Apply:
kubectl apply -f kubernetes/pdb.yaml
kubectl get pdb -n jumptotech-prod
14. HPA
Now introduce automatic scaling.
kubernetes/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: jumptotech-hpa
namespace: jumptotech-prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: jumptotech-web
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Explain:
Low traffic
Pod Pod Pod
CPU increases
↓
HPA observes metric
↓
desired replicas increases
↓
Pod Pod Pod Pod Pod ...
Traffic decreases
↓
scale down
For resource-based CPU HPA to work as expected, the cluster needs resource metrics available, commonly through Metrics Server, and CPU requests matter for utilization calculations.
Check first:
kubectl top pods -n jumptotech-prod
Then:
kubectl apply -f kubernetes/hpa.yaml
kubectl get hpa -n jumptotech-prod
15. Ingress
kubernetes/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: jumptotech-ingress
namespace: jumptotech-prod
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: jumptotech-service
port:
number: 80
Before applying:
kubectl get deployment \
aws-load-balancer-controller \
-n kube-system
Then:
kubectl apply -f kubernetes/ingress.yaml
Check:
kubectl get ingress -n jumptotech-prod
kubectl describe ingress \
jumptotech-ingress \
-n jumptotech-prod
Teach the two flows separately.
Configuration:
Ingress YAML
↓
Kubernetes API
↓
AWS Load Balancer Controller
↓
AWS APIs
↓
ALB / target groups / rules
Runtime traffic is different:
Customer
↓
ALB
↓
configured backend targets
↓
Pods/application
The controller is not sitting in the middle of every customer HTTP request.
PART 3 — Why do we need Helm?
Now ask students:
What happens if we need DEV, TEST and PROD?
Without Helm we may start duplicating files:
deployment-dev.yaml
deployment-test.yaml
deployment-prod.yaml
service-dev.yaml
service-test.yaml
service-prod.yaml
ingress-dev.yaml
ingress-test.yaml
ingress-prod.yaml
This becomes difficult to maintain.
Helm gives us:
Templates
+
Values
=
Rendered Kubernetes YAML
A simple analogy:
Kubernetes YAML = completed form
Helm template = reusable form
values.yaml = answers inserted
into the form
PART 4 — Create Helm Chart
Run:
helm create jumptotech
For the class, simplify the generated chart and use:
helm/jumptotech/
├── Chart.yaml
├── values.yaml
└── templates/
├── _helpers.tpl
├── configmap.yaml
├── serviceaccount.yaml
├── deployment.yaml
├── service.yaml
├── pdb.yaml
├── hpa.yaml
└── ingress.yaml
16. Chart.yaml
apiVersion: v2
name: jumptotech
description: JumpToTech production Kubernetes application
type: application
version: 0.1.0
appVersion: "1.0.0"
Explain:
Chart.yaml
↓
information about Helm chart
17. values.yaml
This becomes the central configuration.
replicaCount: 3
image:
repository: YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/jumptotech-web
tag: "v1"
pullPolicy: IfNotPresent
serviceAccount:
create: true
name: jumptotech-sa
service:
type: ClusterIP
port: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
config:
appName: JumpToTech
environment: production
logLevel: info
probes:
readiness:
path: /
initialDelaySeconds: 5
liveness:
path: /
initialDelaySeconds: 15
pdb:
enabled: true
minAvailable: 2
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
ingress:
enabled: true
className: alb
scheme: internet-facing
targetType: ip
Now students should see the point.
Instead of editing templates repeatedly:
values.yaml
↓
Helm templates
↓
Kubernetes manifests
18. Helm Deployment template
templates/deployment.yaml
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:
serviceAccountName: {{ .Values.serviceAccount.name }}
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
envFrom:
- configMapRef:
name: {{ .Release.Name }}-config
resources:
{{ toYaml .Values.resources | indent 12 }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
Now explain:
{{ .Values.replicaCount }}
means:
Go to
values.yamland getreplicaCount.
And:
{{ .Values.image.repository }}
gets:
image:
repository: ...
19. Helm Service
templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-service
spec:
type: {{ .Values.service.type }}
selector:
app: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
20. Helm ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
APP_NAME: {{ .Values.config.appName | quote }}
APP_ENV: {{ .Values.config.environment | quote }}
LOG_LEVEL: {{ .Values.config.logLevel | quote }}
21. Helm ServiceAccount
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.serviceAccount.name }}
{{- end }}
Now introduce conditional rendering:
if true
↓
create object
if false
↓
don't render object
22. Helm PDB
{{- if .Values.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Release.Name }}-pdb
spec:
minAvailable: {{ .Values.pdb.minAvailable }}
selector:
matchLabels:
app: {{ .Release.Name }}
{{- end }}
23. Helm HPA
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ .Release.Name }}-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ .Release.Name }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
24. Helm Ingress
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
annotations:
alb.ingress.kubernetes.io/scheme: {{ .Values.ingress.scheme }}
alb.ingress.kubernetes.io/target-type: {{ .Values.ingress.targetType }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-service
port:
number: {{ .Values.service.port }}
{{- end }}
PART 5 — Never install a Helm chart before checking it
This is an important production habit.
First:
helm lint helm/jumptotech
Then:
helm template jumptotech \
helm/jumptotech
Explain what happened:
values.yaml
+
templates/
↓
HELM RENDERING
↓
ordinary Kubernetes YAML
Helm doesn't replace Kubernetes.
Helm generates/manages Kubernetes manifests/releases. Kubernetes still runs the workloads.
25. Install with Helm manually
Before introducing Argo CD:
helm upgrade --install jumptotech \
helm/jumptotech \
--namespace jumptotech-prod \
--create-namespace
Check:
helm list -n jumptotech-prod
kubectl get all -n jumptotech-prod
Then:
helm status jumptotech \
-n jumptotech-prod
Now students understand Helm independently.
PART 6 — DEV vs PROD
Create:
values-dev.yaml
values-prod.yaml
Example DEV:
replicaCount: 1
config:
environment: development
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
autoscaling:
enabled: false
PROD:
replicaCount: 3
config:
environment: production
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 70
Now:
Same Helm templates
┌── values-dev.yaml
│
Chart ┤
│
└── values-prod.yaml
That's one of the major benefits students need to understand.
PART 7 — Why Argo CD?
Now ask:
We have Helm. Why do we need Argo CD?
Without Argo CD:
Developer
↓
git push
↓
Someone runs:
helm upgrade ...
↓
Cluster
With GitOps:
Developer
↓
git push
↓
GitHub
↓
Argo CD detects desired state
↓
Helm chart rendered
↓
Kubernetes
Git becomes the source of desired deployment configuration.
PART 8 — Argo CD Application
Assuming Argo CD is already installed, create:
argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: jumptotech-production
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/jumptotechschooldevops/YOUR_REPOSITORY.git
targetRevision: main
path: helm/jumptotech
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: jumptotech-prod
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Explain each important field.
repoURL
↓
Where is Git repository?
targetRevision
↓
Which branch/tag/commit?
path
↓
Where is Helm chart?
destination
↓
Which cluster?
namespace
↓
Where should resources go?
26. selfHeal
This is a fantastic live demonstration.
Git says:
replicas: 3
Someone manually runs:
kubectl scale deployment jumptotech \
--replicas=1 \
-n jumptotech-prod
Now:
Git desired state
3 replicas
Cluster actual state
1 replica
Argo CD detects drift.
With automated self-healing configured, it can reconcile the cluster back toward the Git-defined state.
Teach:
GIT = Desired State
CLUSTER = Actual State
Argo CD
↓
compares
↓
reconciles differences
27. prune
Suppose Git previously contained:
service.yaml
deployment.yaml
old-configmap.yaml
Then you remove the managed old ConfigMap from Git.
With:
prune: true
Argo CD can remove the resource that is no longer part of the application's desired state.
Be careful with this in real production environments: automated pruning is powerful.
PART 9 — Complete CI/CD + GitOps architecture
Now students should be able to draw:
DEVELOPER
│
git push
│
┌───────▼────────┐
│ GitHub │
└───────┬────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
GitHub Actions Argo CD
│ │
CI Pipeline GitOps CD
│ │
Test / Scan │
│ │
Docker Build │
│ │
▼ │
ECR │
│ │
Image:v2 │
│
Helm templates
+
values
│
▼
EKS Cluster
│
jumptotech-prod
│
┌──────┴──────┐
│ │
Deployment Service
│ │
ReplicaSet │
│ │
Pods/Containers ◄───┘
▲
│
ALB
▲
│
Customer
There's an important distinction here:
CI builds the artifact.
Code
↓
Test
↓
Docker build
↓
Image
↓
ECR
GitOps CD changes desired deployment state and reconciles it.
Git desired state
↓
Argo CD
↓
Helm
↓
Kubernetes
PART 10 — Production files students should recognize
| Object/File | Main purpose |
|---|---|
Namespace |
Logical resource boundary |
ConfigMap |
Non-sensitive configuration |
Secret |
Sensitive configuration object |
ServiceAccount |
Workload identity |
Role |
Namespaced RBAC permissions |
RoleBinding |
Assign Role permissions |
Deployment |
Desired stateless application deployment |
ReplicaSet |
Maintains Pod replica count |
Pod |
Workload execution unit |
Service |
Stable service discovery/access |
EndpointSlice |
Service backend addresses |
Ingress |
HTTP/HTTPS routing configuration |
NetworkPolicy |
Pod network traffic policy |
PDB |
Availability during voluntary disruptions |
HPA |
Horizontal workload scaling |
PVC |
Persistent storage request |
StatefulSet |
Stateful workloads |
Job |
Run-to-completion workload |
CronJob |
Scheduled Job |
Chart.yaml |
Helm chart metadata |
values.yaml |
Helm configuration |
templates/* |
Helm Kubernetes templates |
Argo Application
|
Defines a GitOps-managed application |
Top comments (0)