DevOps module 3: infrastructure as code, Kubernetes and secrets
Fourteen sessions to stop configuring by hand: machines described in Ansible playbooks, infrastructure in OpenTofu files reviewed like code, an application run by Kubernetes, and secrets that never touch the repository. The module ends with hello-ops deployed three ways from code alone.
Everything runs on your own machine: Ansible against your VM (or a second one), OpenTofu driving your local Docker, Kubernetes inside Docker with kind. No cloud account is needed. Addresses are documentation ranges (192.0.2.0/24, 198.51.100.0/24).
Day 29 · 01/12 — Infrastructure as code: why, and its history
Goal: explain declarative, idempotent and drift, and place the main tools in time.
Theory 1/2: pets and cattle
A server configured by hand, nursed when ill and irreplaceable, is a pet; servers built from a description, replaced rather than repaired, are cattle. The image is credited to Bill Baker (Microsoft, mid-2000s) and was spread by Randy Bias around 2012. Infrastructure as code (IaC) is that description: text files, versioned in Git, reviewed in pull requests, applied by a tool. The machine then matches a file, not a memory.
A server breaks. In the "cattle" approach you…
- log in and repair it by hand
- replace it with a new one built from the code
Answer
Rebuild from the description; the code is the reference.
Theory 2/2: declarative, idempotent, drift
Imperative: the steps ("install nginx, then copy the file"). Declarative: the desired state ("nginx is installed, this file has this content"); the tool works out the steps. Idempotent: applying it twice gives the same result as once; the second run changes nothing. Drift: the real system no longer matches the code, because someone changed it by hand.
A little history: CFEngine (Mark Burgess, 1993), Puppet (2005), Chef (2009), Salt (2011), Ansible (Michael DeHaan, 2012, bought by Red Hat in 2015) manage what is on machines; Terraform (HashiCorp, 2014) and its open fork OpenTofu manage the machines, networks and services themselves.
You run the same playbook twice; the second run reports 0 changes. The playbook is…
- idempotent
- broken
Answer
Idempotent: the state was already the desired one.
What is the name for the gap between the code and the real system after manual changes?
Answer
Drift.
echo "line" >> /etc/app.conf run twice is…
- idempotent
- not idempotent: the line is added twice
Answer
Appending is not idempotent; "this line is present" is.
Practice (30 min)
- List in your notebook everything you did by hand on your VM since day 1 (packages, users, files, services). That list is what IaC would describe.
- Rewrite three of those items as desired states ("package X is present"), not steps.
- Install Ansible as the installation guide recommends (for example
pipx install --include-deps ansible), and checkansible --version.
With your AI: "Give me eight shell commands one at a time and ask me, for each, whether it is idempotent and how to make it idempotent if it is not. Correct me with one sentence each. Start with mkdir, echo >>, useradd and sed -i."
Recap (10 min)
Three lines: pets and cattle, declarative and idempotent, drift. Then place CFEngine, Puppet, Ansible and Terraform on a timeline from memory.
Day 30 · 02/12 — Ansible 1: inventory and ad-hoc commands
Goal: reach machines with Ansible and run modules on them.
Theory 1/2: agentless, over SSH
Ansible installs nothing on the targets: it connects over SSH (day 9), copies small Python programs called modules, runs them and reads their result. The inventory lists the machines, in groups:
# inventory.ini
[web]
web1 ansible_host=192.0.2.21
[local]
localhost ansible_connection=local
What does Ansible need on a target machine?
- An Ansible agent running
- SSH and Python
Answer
Agentless: SSH access and Python on the target.
Theory 2/2: modules and ad-hoc commands
An ad-hoc command runs one module on a group, without a file. Modules are named by collection: ansible.builtin.ping, ansible.builtin.apt, ansible.builtin.copy… Each reports ok (nothing to do) or changed.
ansible all -i inventory.ini -m ansible.builtin.ping
ansible local -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution*"
ansible local -i inventory.ini -b -m ansible.builtin.apt -a "name=tree state=present"
-b ("become") runs with sudo; -K asks for its password.
Which option makes an Ansible command run with privilege escalation?
Answer
-b (--become).
You run the apt command above twice. The second time it says…
ok, because tree is already presentchangedagain
Answer
state=present is a desired state: nothing to do the second time.
Practice (30 min)
- Create
~/lab/ansible/inventory.iniwith alocalgroup (and awebgroup if you have a second VM reachable by SSH key). - Run
ping, thensetupwith the filter: note the distribution Ansible sees. - Install
treewith theaptmodule and-b -K; run it again and comparechangedandok. - Remove it with
state=absent. - Read
ansible-doc ansible.builtin.aptand note two options you did not know.
With your AI: "I am learning Ansible ad-hoc commands. Give me six tasks (check uptime, create a folder, install a package, copy a file, restart a service, gather facts), one at a time; I answer with the command, you check the module name and arguments against ansible.builtin and tell me if I got them wrong."
Let an agent do it: an agent can run ad-hoc checks across an inventory and summarise (disk use, kernel version). Ask it to show each command. You still check one host by hand, and you never let it run -b with a state change without reading the command first.
Recap (10 min)
Three lines: agentless over SSH, the inventory, ok versus changed. Then redo the ping and apt commands without notes.
Day 31 · 03/12 — Ansible 2: playbooks, idempotence, handlers
Goal: write a playbook that prepares a host for hello-ops, and preview its effect before applying.
Theory 1/2: a playbook
# site.yml
- name: Prepare an app host
hosts: local
become: true
tasks:
- name: Install packages
ansible.builtin.apt:
name: [curl, ca-certificates]
state: present
update_cache: true
- name: Create the app user
ansible.builtin.user:
name: app
system: true
create_home: false
- name: Create the app folder
ansible.builtin.file:
path: /srv/app
state: directory
owner: app
mode: "0750"
A play targets hosts; its tasks call modules in order. Run it with ansible-playbook -i inventory.ini site.yml -K; the recap at the end counts ok, changed, failed.
Why quote "0750"?
- Style only
- Without quotes YAML may read it as a number and the mode comes out wrong
Answer
Give modes as quoted strings; the Ansible docs warn about it.
Theory 2/2: check, diff, handlers
--check simulates (nothing is changed) and --diff shows what would change in files: run both before every real apply. A handler runs only when a task notify-ed it and that task changed something: restart a service only if its configuration changed.
- name: Install the unit file
ansible.builtin.copy:
src: hello-ops.service
dest: /etc/systemd/system/hello-ops.service
mode: "0644"
notify: Restart hello-ops
handlers:
- name: Restart hello-ops
ansible.builtin.systemd_service:
name: hello-ops
state: restarted
daemon_reload: true
Which two options preview a playbook's changes without applying them?
Answer
--check --diff.
The unit file is unchanged on this run. Does the handler restart the service?
- No, the task reported ok, not changed
- Yes, every run
Answer
Handlers fire on change only.
Practice (30 min)
- Write
site.ymlwith the three tasks; run it with--check --diff, then for real, then a second time: the second recap showschanged=0. - Add a unit
hello-ops.servicethat runspython3 /srv/app/app.pyas userapp, a task copyingapp.pyto/srv/app, the unit task with its handler, and a task that enables and starts the service. - Apply;
curl http://127.0.0.1:8080/health. Change the unit's description, apply with--diff: the handler fires once. - Commit the playbook in a new repository
hello-ops-infra.
With your AI: "Review this Ansible playbook for idempotence and safety: [paste site.yml]. For each task, say whether a second run would report changed and why. Flag any shell or command module that could be replaced by a proper module. Answer as a table."
Let an agent do it: agents write playbooks fast, often with shell: tasks that are not idempotent. Instruction: "Add a task that installs and enables fail2ban using only ansible.builtin modules; no shell or command module; run with --check --diff and show me the output." You still check: run it twice for real and read the second recap (changed=0).
Recap (10 min)
Three lines: play and tasks, check and diff, handlers. Then rewrite the handler block from memory.
Day 32 · 04/12 — Ansible 3: variables, templates, roles
Goal: turn the playbook into a reusable role with a template and variables, and lint it.
Theory 1/2: variables and Jinja2 templates
Variables are set in the inventory, in group_vars/, in a role's defaults/, or on the command line; their order of precedence is documented, and role defaults are the weakest, so easy to override. A template is a file with Jinja2 placeholders, filled in by ansible.builtin.template:
# templates/hello-ops.service.j2
[Service]
User={{ app_user }}
Environment=PORT={{ app_port }}
ExecStart=/usr/bin/python3 {{ app_dir }}/app.py
Which module renders a Jinja2 file onto the target?
Answer
ansible.builtin.template.
Theory 2/2: roles, Galaxy, lint
A role packages tasks, handlers, templates, files and defaults in a fixed tree (ansible-galaxy role init hello_ops creates it), so a playbook only says roles: [hello_ops]. Ansible Galaxy shares roles and collections; read one before using it, like any third-party code. ansible-lint checks playbooks against good practices (full module names, named tasks, no needless shell).
Where do you put a role variable meant to be overridden easily?
vars/main.ymldefaults/main.yml
Answer
Defaults have the lowest precedence: anything else overrides them.
ansible-lint warns fqcn on apt:. What does it want?
- The full name
ansible.builtin.apt - A different package
Answer
Fully qualified collection names remove ambiguity.
Practice (30 min)
ansible-galaxy role init roles/hello_ops; move your tasks, handler and unit into it; turn the unit into the template above, withapp_user,app_port,app_dirindefaults/main.yml.- Reduce
site.ymlto the play androles: [hello_ops]; apply; then overrideapp_port=8090with-eand check withcurl. - Install and run
ansible-lint; fix every finding.
With your AI: "Explain Ansible variable precedence with my case: app_port is in roles/hello_ops/defaults/main.yml (8080), in group_vars/local.yml (8085) and passed with -e (8090). Which value wins and why, and where would you put each kind of variable? Point me to the documentation page to verify."
Let an agent do it: converting a flat playbook into a role is mechanical work agents do well. Instruction: "Convert site.yml into a role hello_ops with defaults for every literal value; run ansible-lint and a --check --diff; show both outputs." You still check that the second real run shows changed=0 and that no value moved silently.
Recap (10 min)
Three lines: templates, a role's tree, ansible-lint. Then rewrite the template from memory. (The site's article Rebuild your machines in one command shows a real workstation role.)
Day 33 · 05/12 — OpenTofu 1: providers, plan, apply, state
Goal: describe a container in OpenTofu, preview it, create it and destroy it.
Theory 1/2: Terraform, the licence change and OpenTofu
HashiCorp released Terraform in 2014 under an open source licence (MPL 2.0). In August 2023 it moved its products to the Business Source License, which is not open source. Part of the community forked the last open version as OpenTofu, under the Linux Foundation; its first stable release, 1.6.0, came out in January 2024, and it joined the CNCF in 2025. The language (HCL) and the workflow are the same; the command is tofu instead of terraform. This path uses OpenTofu.
Why does OpenTofu exist?
- Terraform stopped working
- Terraform's licence changed to a non-open-source one in 2023, and the community forked the last open version
Answer
The BSL change of August 2023.
Theory 2/2: providers, resources, plan, state
A provider is a plugin that speaks to an API (a cloud, DNS, Docker…); a resource is one object it manages. The cycle: tofu init (download providers), tofu plan (compare the code with reality and show what will be created +, changed ~, destroyed -), tofu apply, tofu destroy. OpenTofu remembers what it created in a state file, terraform.tfstate: it may hold secrets, so it never goes in Git.
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
}
}
}
provider "docker" {}
resource "docker_image" "app" {
name = "hello-ops:dev"
keep_locally = true
}
resource "docker_container" "app" {
name = "hello-ops"
image = docker_image.app.image_id
ports {
internal = 8080
external = 8080
}
}
Which command shows what would change, without changing anything?
Answer
tofu plan.
Should terraform.tfstate be committed?
- No: it can contain secrets, and it belongs in a protected backend
- Yes, like the code
Answer
Never in Git; add *.tfstate* and .terraform/ to .gitignore.
Practice (30 min)
- Install OpenTofu following the official guide;
tofu version. - In
hello-ops-infra/tofu/, writemain.tfabove (check the provider's current version on the OpenTofu registry). tofu init,tofu plan(read every+),tofu apply,curl http://127.0.0.1:8080/health.- Change the external port to 8081,
tofu plan: does it update in place or replace? Apply, thentofu destroy. - Create a
.gitignorewith.terraform/and*.tfstate*, and commit (the.terraform.lock.hclfile is committed).
With your AI: "Here is the output of tofu plan: [paste it]. Explain each symbol and each resource line, then tell me which change forces a replacement and why. Under 15 lines."
Let an agent do it: an agent can write HCL for a resource you describe. You still read every line of tofu plan before any apply: an agent must never run apply or destroy unattended on infrastructure you care about.
Recap (10 min)
Three lines: why OpenTofu, init / plan / apply / destroy, what the state is. Then rewrite the container resource from memory.
Day 34 · 06/12 — OpenTofu 2: variables, outputs, modules, remote state
Goal: parameterise the configuration, package it as a module, and know where state lives in a team.
Theory 1/2: variables, outputs, modules
variable "image_tag" {
type = string
description = "Image tag to deploy"
default = "dev"
}
output "url" {
value = "http://127.0.0.1:${docker_container.app.ports[0].external}"
}
Values come from -var, a *.tfvars file or TF_VAR_image_tag. A module is a folder of .tf files called with module "name" { source = "./modules/app" … }: the same code deploys staging and production with different variables.
How do you pass image_tag on the command line?
tofu apply image_tag=1.0tofu apply -var image_tag=1.0
Answer
-var name=value.
Theory 2/2: state in a team
Two people applying with two local state files corrupt each other's picture of reality. Teams keep the state in a remote backend (an object store, a database, a forge's backend) with locking, so only one apply runs at a time. OpenTofu can also encrypt the state and plan files itself (since version 1.7), a feature Terraform does not have.
What does state locking prevent?
- Two applies changing the same infrastructure at the same time
- Reading the code
Answer
Concurrent writes to the state.
Which command prints the outputs after an apply?
Answer
tofu output.
Practice (30 min)
- Add
image_tagandexternal_portvariables and theurloutput; apply with-var external_port=8082;tofu output url. - Move the image and container into
modules/app/, and call it twice from the root (stagingon 8083,prodon 8084, different container names). Plan, apply, curl both, destroy. - Read the state encryption page and note in your notebook how a key would be supplied without writing it in a file.
With your AI: "I have one OpenTofu module deployed twice (staging and prod) from the same root. Compare three ways to separate environments (two module calls, workspaces, two root folders) in a table: isolation of state, risk of applying to the wrong one, duplication. Mark anything you are unsure of."
Let an agent do it: an agent can extract a module from a flat configuration. Instruction: "Move these resources into modules/app without changing behaviour; run tofu fmt, validate and plan; the plan must show only moves, no destroy." You still read the plan: a refactor that destroys and recreates is not a refactor (use moved blocks).
Recap (10 min)
Three lines: variables and outputs, modules, remote state with locking. Then rewrite the variable block from memory.
Day 35 · 07/12 — Infrastructure as code in the pipeline, and prompting for it
Goal: check infrastructure code in CI like application code, and write a prompt for IaC you can verify.
Theory 1/3: the IaC pipeline
On a pull request: tofu fmt -check (format), tofu validate (syntax and references), tofu plan (the plan is posted for the reviewer), ansible-lint and a --check run. On merge to main: apply, with a human approval for production (an environment with required reviewers on GitHub). A scheduled tofu plan that should show "No changes" detects drift.
A nightly tofu plan shows one change nobody committed. What is it?
- A bug in OpenTofu
- A new feature
- Drift: someone changed the infrastructure by hand
Answer
Drift. Bring the change into the code, or undo it.
Which command fails if the files are not formatted?
Answer
tofu fmt -check.
Theory 2/3: prompting for infrastructure code
A weak prompt: "write terraform for a web server". What is wrong: no tool (Terraform or OpenTofu, which version?), no provider, no environment, no rule about what may be destroyed, no way to check. The model will pick a cloud you do not use, a provider version from its memory, and maybe a security group open to the world.
The edited prompt:
Role: you are an infrastructure engineer reviewing for safety.
Context: OpenTofu (tofu version prints the version), provider
kreuzwerker/docker ~> 4.0, local Docker, module in modules/app.
Task: add a variable "replicas" (number, default 1) and create that
many containers named hello-ops-0, hello-ops-1..., ports 8080 + index.
Constraints: no new provider; no change to existing names or the
plan must show no destroy; tofu fmt clean; descriptions on variables.
Inputs: modules/app/main.tf and variables.tf: [paste both].
Output: the changed files in full, then the expected tofu plan summary.
Check: I will run tofu fmt -check, tofu validate and tofu plan, and
compare the plan with your expected summary.
Why ask the model for "the expected plan summary"?
- To make the answer longer
- So you can compare it with the real
tofu planand catch a misunderstanding
Answer
A prediction you can check against the tool is a test of the answer.
Theory 3/3: fix this prompt
"Write an Ansible playbook to secure my server." Which addition matters most?
- The distribution and version, the exact measures wanted (SSH keys only, firewall rules), "ansible.builtin modules only, idempotent", and "I will run it with --check --diff first"
- "You are the best security expert in the world"
Answer
Context, constraints and the check; flattering roles add nothing.
Practice (30 min)
- In
hello-ops-infra, add a workflow that runs on pull requests:tofu fmt -check,tofu init -backend=false,tofu validate, andansible-lint. Install OpenTofu in the job as the install guide says for CI, or use its official container image. - Open a pull request with a badly formatted
.tffile: the check fails. Fix it withtofu fmt. - Send the edited prompt above with your real files; run fmt, validate and plan on the answer; compare the plan with its prediction.
With your AI: "Here is my weak prompt: 'write a playbook that deploys my app'. Rewrite it with role, context, task, constraints, inputs, output format and check, for my real setup: Debian 13, role hello_ops, app.py in /srv/app, systemd. Then explain each line you added."
Let an agent do it: an agent can open the IaC pull requests itself, and even comment the plan. You still review the plan and approve the apply: the approval step exists precisely so a human reads what will change.
Recap (10 min)
Three lines: what runs on a pull request, what runs on merge, how drift is detected; and the parts of an IaC prompt. Then rewrite the weak web-server prompt properly.
Day 36 · 08/12 — Kubernetes 1: why orchestrate, the architecture
Goal: explain what an orchestrator does and name the parts of a Kubernetes cluster.
Theory 1/2: from Compose to an orchestrator
Compose runs containers on one machine. With many machines you need something that places containers, restarts them when they die, replaces a failed node, spreads traffic and rolls updates: an orchestrator. Google ran one internally, Borg; it announced Kubernetes (Greek for helmsman) in June 2014, released 1.0 in July 2015 and gave it to the new Cloud Native Computing Foundation (CNCF), where it was the first project to graduate (2018). Kubernetes is often written k8s.
Which internal Google system inspired Kubernetes?
- MapReduce
- Borg
Answer
Borg.
Theory 2/2: desired state and the architecture
You declare a desired state (three copies of this image); controllers compare it with reality, endlessly, and act to close the gap. The control plane: the API server (everything goes through it, kubectl included), etcd (the key-value store holding the state), the scheduler (picks a node for each Pod), the controller manager. Each node runs the kubelet (starts the Pods the API asks for, through a container runtime such as containerd) and kube-proxy (service networking).
Which component stores the cluster's state?
Answer
etcd.
A node dies with two Pods of a three-replica Deployment. What happens?
- The controller sees one replica instead of three and creates two on other nodes
- Nothing until an admin intervenes
Answer
The reconciliation loop restores the desired state.
Which component picks the node a new Pod runs on?
- kubelet
- etcd
- the scheduler
Answer
The scheduler; the kubelet then starts it.
Practice (30 min)
- Draw the architecture in your notebook: control plane (API server, etcd, scheduler, controller manager), two nodes (kubelet, kube-proxy, runtime, Pods), and
kubectltalking to the API server. - Do the first module of the official Kubernetes Basics tutorial.
- Install
kubectl(guide) and kind; checkkubectl version --clientandkind version.
With your AI: "Quiz me on Kubernetes architecture: describe eight events (kubectl apply, a node failure, a Pod crash, a new Pod to place…), one at a time, and ask me which component acts first and what it does. Correct me with one sentence."
Recap (10 min)
Three lines: what an orchestrator does, desired state and reconciliation, the parts of the control plane. Then redraw the architecture without notes.
Day 37 · 09/12 — Kubernetes 2: a cluster with kind, Pods and Deployments
Goal: run hello-ops in a local cluster with three replicas.
Theory 1/2: Pod, ReplicaSet, Deployment
A Pod is the smallest unit: one or more containers sharing a network address. You rarely create Pods directly: a Deployment declares the image and the number of replicas, and manages a ReplicaSet that keeps that many Pods alive. Objects are YAML manifests applied with kubectl apply -f.
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-ops
spec:
replicas: 3
selector:
matchLabels:
app: hello-ops
template:
metadata:
labels:
app: hello-ops
spec:
containers:
- name: app
image: hello-ops:dev
ports:
- containerPort: 8080
You delete one Pod of this Deployment. What happens?
- Two Pods remain
- The ReplicaSet creates a new one to get back to three
Answer
Desired state: three.
Theory 2/2: kubectl, the daily commands
kubectl get pods -o wide
kubectl describe pod NAME # events: why it does not start
kubectl logs NAME # container output
kubectl exec -it NAME -- sh
kubectl scale deployment hello-ops --replicas=5
kubectl delete -f deploy.yaml
A Pod stuck in ImagePullBackOff cannot get its image; CrashLoopBackOff starts and keeps crashing: read describe, then logs.
Which command shows a Pod's events, such as an image that cannot be pulled?
Answer
kubectl describe pod NAME.
A Pod shows CrashLoopBackOff. Next command?
kubectl logs(with--previousfor the crashed run)- Reinstall the cluster
Answer
The app is crashing: its logs say why.
Practice (30 min)
kind create cluster --name lab;kubectl get nodes.- Load your local image into the cluster:
kind load docker-image hello-ops:dev --name lab. - Save the manifest as
k8s/deploy.yaml, addimagePullPolicy: IfNotPresentto the container, apply it,kubectl get podsuntil three are Running. - Delete a Pod and watch it come back:
kubectl get pods -w. Scale to 5, then back to 3. - Break it: set the image to
hello-ops:nope, apply, readdescribe. Fix it.
With your AI: "Here is kubectl describe pod for a Pod that will not start: [paste]. Point to the event line that explains it, say what it means in one sentence, and give the single command that would confirm it. Do not give me a fix yet."
Let an agent do it: an agent with kubectl access can triage a failing Pod (describe, logs, events) and propose the fix. Restrict it to a test cluster and to read commands first. You still check the cause in the events yourself and apply the fix through the manifest in Git, not with a live edit.
Recap (10 min)
Three lines: Pod, ReplicaSet, Deployment; the five kubectl commands; the two common failure states. Then rewrite the Deployment from memory.
Day 38 · 10/12 — Kubernetes 3: Services, ConfigMaps, namespaces, traffic in
Goal: give the Pods one stable address, configure them from outside the image, and know how traffic enters a cluster.
Theory 1/2: Services and namespaces
Pods come and go with new addresses. A Service gives a stable name and address and spreads traffic over the Pods whose labels match its selector. Types: ClusterIP (inside the cluster, the default), NodePort (a port on every node), LoadBalancer (an external load balancer, in clouds). Inside the cluster, the Service is reachable by DNS: hello-ops.default.svc.cluster.local, or just hello-ops from the same namespace. A namespace groups objects (per team, per environment).
apiVersion: v1
kind: Service
metadata:
name: hello-ops
spec:
selector:
app: hello-ops
ports:
- port: 80
targetPort: 8080
How does a Service know which Pods to send traffic to?
- By their names
- By its label selector
Answer
Labels: app: hello-ops.
Which Service type is the default, reachable only inside the cluster?
Answer
ClusterIP.
Theory 2/2: ConfigMaps, and traffic from outside
A ConfigMap holds configuration as key-value pairs, injected as environment variables or files: the same image runs everywhere with different settings (the twelve-factor app, factor III). To route HTTP from outside, Kubernetes has the Ingress API and its successor, the Gateway API; both need a controller installed. The most used controller, ingress-nginx, was retired by the Kubernetes project in March 2026 (no more fixes); the project points new installations to Gateway API implementations.
Where should APP_VERSION come from in Kubernetes?
- A ConfigMap (or the Deployment's env), not baked into the image
- A file edited inside the running Pod
Answer
Configuration lives outside the image.
Practice (30 min)
- Apply the Service;
kubectl get svc; reach it from your machine withkubectl port-forward svc/hello-ops 8080:80andcurl http://127.0.0.1:8080/. - From inside:
kubectl run tmp --rm -it --image=busybox -- wget -qO- http://hello-ops/health. - Create a ConfigMap:
kubectl create configmap hello-ops --from-literal=APP_VERSION=0.2.0 --dry-run=client -o yaml > k8s/config.yaml, apply it, reference it in the Deployment withenvFrom: [{configMapRef: {name: hello-ops}}], apply, curl: the version shows. - Create a namespace
stagingand deploy the same manifests there with-n staging.
With your AI: "Explain the path of an HTTP request from my laptop to a container through kubectl port-forward, a Service and a Pod, step by step. Then explain how it would differ with a Gateway API implementation in front. Keep each step to one line."
Let an agent do it: generating Service and ConfigMap manifests is routine for an agent. Ask it to use kubectl create ... --dry-run=client -o yaml so the output comes from the tool. You still check the selector matches the Pod labels (kubectl get endpoints hello-ops must list addresses).
Recap (10 min)
Three lines: Service and selector, ConfigMap, Ingress and Gateway API. Then rewrite the Service from memory.
Day 39 · 11/12 — Kubernetes 4: probes, resources, rollouts and rollbacks
Goal: make Kubernetes know when the app is healthy, cap its resources, and update and roll back without downtime.
Theory 1/2: probes and resources
A readiness probe says when a Pod may receive traffic; a liveness probe says when to restart it; a startup probe protects slow starts. Requests are what the scheduler reserves for a container; limits are the ceiling (a container over its memory limit is killed: OOMKilled).
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 64Mi
Which probe removes a Pod from the Service without restarting it?
- Liveness
- Readiness
Answer
Readiness: not ready, no traffic. Liveness restarts.
What status does a container get when it exceeds its memory limit?
Answer
OOMKilled.
Theory 2/2: rolling updates and rollbacks
Changing the Deployment's image starts a rolling update: new Pods are created and must be ready before old ones are removed (maxSurge, maxUnavailable). Each version is kept as a revision.
kubectl set image deployment/hello-ops app=hello-ops:v2
kubectl rollout status deployment/hello-ops
kubectl rollout history deployment/hello-ops
kubectl rollout undo deployment/hello-ops
Which command goes back to the previous revision of the hello-ops Deployment?
Answer
kubectl rollout undo deployment/hello-ops.
The new version's readiness probe never passes. During the rolling update…
- the old Pods keep serving; the rollout stalls
- all Pods go down
Answer
Readiness protects the rollout: unready Pods get no traffic and old ones are not removed.
Practice (30 min)
- Add the probes and resources to
k8s/deploy.yaml; apply;kubectl describe podshows them. - Build
hello-ops:v2(change the message), load it with kind,kubectl set image, and watchrollout statuswhile a loop curls through the port-forward: no failed request. - Build a broken
hello-ops:v3whose/healthreturns 500; deploy it: the rollout stalls, the old Pods serve.kubectl rollout undo. - Put the image change in the manifest file, not only on the command line, and commit.
With your AI: "Here is my Deployment with probes and resources: [paste]. For each value, say what happens if it is too low and too high, in a table. Then tell me which value you would check first if Pods restart every few minutes."
Let an agent do it: an agent can watch a rollout and run rollout undo if errors rise. Give it the exact rule ("undo if more than 2 of 20 curls fail"). You still check afterwards which revision runs and why the bad one failed.
Recap (10 min)
Three lines: readiness versus liveness, requests versus limits, rollout and undo. Then rewrite the readiness probe from memory.
Day 40 · 12/12 — Kubernetes 5: Helm, Kustomize, GitOps
Goal: manage several environments without copying manifests, and understand GitOps.
Theory 1/2: Helm and Kustomize
Helm (a CNCF graduated project) packages manifests as a chart: templates plus a values.yaml; helm install renders and applies them, and tracks releases. Kustomize, built into kubectl (kubectl apply -k), keeps plain manifests as a base and applies overlays per environment (replicas, tags, namespace), with no template language.
k8s/
base/ deploy.yaml service.yaml kustomization.yaml
overlays/
staging/ kustomization.yaml # namespace: staging, replicas: 1
prod/ kustomization.yaml # namespace: prod, replicas: 3
Which tool is built into kubectl?
- Helm
- Kustomize
Answer
Kustomize, with kubectl apply -k.
Theory 2/2: GitOps
GitOps, a term coined by Weaveworks in 2017: the Git repository is the single source of truth for what runs; an agent inside the cluster (Argo CD or Flux, both CNCF graduated) pulls the repository and reconciles the cluster to it. Deploying is merging a pull request; a manual change in the cluster is reverted. The CI no longer needs credentials to the cluster: it only pushes images and updates a tag in Git.
In GitOps, how do you deploy version 1.3?
- Merge a change setting the tag to 1.3 in the Git repository
- Run
kubectl set imageon production
Answer
The repository is the truth; the agent applies it.
Someone edits a Deployment directly in a GitOps-managed cluster. Then…
- the change stays forever
- the agent detects the drift and restores what Git says
Answer
Self-healing reconciliation (if enabled).
Practice (30 min)
- Reorganise
k8s/into a Kustomize base and two overlays (staging: 1 replica, prod: 3, different namespaces and ConfigMap values). kubectl kustomize k8s/overlays/prodto see the rendered YAML; thenkubectl apply -kboth overlays;kubectl get pods -A -l app=hello-ops.- Read Argo CD's getting started page and draw in your notebook where it would sit in your pipeline. Optional: install it in the kind cluster and point it at your repository.
With your AI: "Compare pushing deployments from CI with kubectl versus GitOps with Argo CD for my setup (one small app, one cluster, two environments), as a table: credentials needed, audit trail, drift handling, rollback. Then ask me two questions to check I understood."
Let an agent do it: an agent can convert flat manifests into a Kustomize base and overlays. You still diff the rendered output (kubectl kustomize) against what was deployed before: the result must be identical except for the intended per-environment values.
Recap (10 min)
Three lines: Helm, Kustomize, GitOps. Then redraw the base / overlays tree from memory.
Day 41 · 13/12 — Secrets management
Goal: find a secret in a repository, keep secrets encrypted or outside Git, and know what Kubernetes Secrets do and do not protect.
Theory 1/2: where secrets leak
A secret is anything that grants access: password, API token, private key, database URL with a password. They leak through Git history (day 14), CI logs, container images (docker history), shell history, screenshots. Scanners such as Gitleaks search a repository and its history for them, locally or in CI. A leaked secret is rotated first: deleting the commit does not undo the leak.
A token was pushed to a public repository an hour ago. First action?
- Rewrite the Git history
- Revoke and replace the token
Answer
Rotate first: bots scan public repositories within minutes.
Are Kubernetes Secrets encrypted by default?
- No: base64 encoded, which anyone can decode; encryption at rest must be configured
- Yes, with AES
Answer
Base64 is an encoding, not encryption. Restrict access with RBAC and enable encryption at rest.
Theory 2/2: three ways to keep secrets
- The platform's secret store: CI secrets (
${{ secrets.NAME }}, masked in logs), Kubernetes Secrets with restricted access. - Encrypted in Git: SOPS encrypts the values of a YAML or JSON file (keys stay readable for diffs) with age keys or a cloud KMS; only holders of the key decrypt.
- A secrets manager: a service that stores, audits and rotates secrets, and hands them out at runtime: HashiCorp Vault (under the Business Source License since 2023), its open fork OpenBao (Linux Foundation), Infisical…
With SOPS, what stays readable in the encrypted file?
- Nothing
- The values
- The keys (field names), so diffs remain meaningful
Answer
Only values are encrypted.
A secret is used in a CI step with run: echo $TOKEN. Is it safe because the forge masks secrets?
- Yes, masking is a guarantee
- No: masking is best effort (an encoded or split value is not masked); never print secrets
Answer
Masking helps; it does not make printing safe. The tj-actions attack printed secrets double-encoded to avoid it.
Practice (30 min)
- Plant a fake secret in a test repository: the example access key that AWS publishes in its own documentation (search "AWS example access key"), written as
aws_secret_access_key = ...in aconfigfile. Commit, then delete it in a second commit. - Run Gitleaks on it (its Docker image or a release binary; read its README for the current command, for example
gitleaks git -v .): it finds the secret in the history. - Install SOPS and age;
age-keygen -o ~/.config/sops/age/keys.txt; encrypt asecrets.yamlwithsops --encrypt --age <your public key> secrets.yaml > secrets.enc.yaml; open it withsops --decrypt. Commit only the encrypted file. - Create a Kubernetes Secret, then read it back with
kubectl get secret NAME -o yamland decode the value withbase64 -d: see why access control matters.
With your AI: "Here is how my project handles secrets today: [describe: .env file on the server, CI secret for the registry, Kubernetes Secret created by hand]. List the risks in order, and for each one the smallest change that reduces it. Do not ask me to paste any secret value."
Let an agent do it: an agent can add a Gitleaks job to CI and a pre-commit hook. It must never be given a real secret to "test" with. You still check: the job fails on the planted fake secret, and passes once it is removed from the files and allow-listed history is justified.
Recap (10 min)
Three lines: where secrets leak, rotate first, the three ways to keep them. Then redo the SOPS encrypt and decrypt without notes.
Day 42 · 14/12 — Module 3 deliverable: hello-ops from code alone
Goal: deliver an infrastructure repository that deploys hello-ops three ways, with no manual step, and check what you learnt.
Theory 1/2: the acceptance test of IaC
The test of infrastructure as code is brutal and simple: delete everything, run the code, and get the same result. If a step needs you to remember something, it is not in the code yet. Write that test down as a README section: "from a fresh machine to a running app".
Your playbook works, but only after you created a folder by hand once. Is the infrastructure fully in code?
- No: the folder must be a task
- Yes
Answer
Any step from memory is a gap.
Theory 2/2: module review
Which command must you read before every tofu apply?
tofu inittofu plan
Answer
The plan says what will be created, changed or destroyed.
A Pod is Running but receives no traffic from its Service. First check?
- Restart the cluster
- Raise the memory limit
- The Service selector against the Pod labels, and the readiness probe
Answer
kubectl get endpoints shows whether the Service found ready Pods.
The two ansible-playbook options to preview a run?
Answer
--check --diff.
Base64 in a Kubernetes Secret is…
- an encoding, not a protection
- strong encryption
Answer
Anyone with read access decodes it.
Practice (30 min)
- Finish
hello-ops-infra:ansible/(rolehello_ops, systemd deployment),tofu/(module, two environments, Docker provider),k8s/(Kustomize base and overlays with probes and resources), a CI workflow (fmt, validate, ansible-lint,kubectl kustomizerenders), secrets encrypted with SOPS or absent. - Run the acceptance test:
kind delete cluster,tofu destroy, then recreate everything from the README alone. - Run Gitleaks on the whole history: clean.
- Add it to your portfolio list: deliverable 3.
With your AI: "Here is the README of my infrastructure repository, section 'from a fresh machine to a running app': [paste]. Find every step that assumes something not written (a tool installed, a variable set, a file present). List them; do not rewrite the README."
Let an agent do it: an agent is a good acceptance tester: give it a fresh VM and only your README, and ask it to follow it literally and report where it got stuck. You still read its log: each place it improvised is a missing line in your README.
Recap (10 min)
Count your score. The missed topics come back on day 56, when the final project is deployed.
Sources
- Ansible documentation and ansible-lint
- OpenTofu documentation, state encryption, and the kreuzwerker/docker provider
- HashiCorp — adopting the Business Source License (August 2023)
- Kubernetes documentation and kind
- Kubernetes blog — Ingress NGINX retirement and Gateway API
- Helm, Kustomize, Argo CD, Flux
- Kubernetes — Secrets, SOPS, age, OpenBao, Gitleaks
- The Twelve-Factor App
Found this useful? Tell us what to measure next.