Initial commit: GitOps Starter Pack

A teaching-first, single-cluster GitOps setup: Talos Linux + Argo CD with
an app-of-apps layout, MetalLB pre-installed for real LoadBalancer IPs on
bare metal, a demo app, and a progressive docs/ walkthrough with a
CLAUDE.md tutor persona and guided bootstrap checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maxwell Morris
2026-08-22 17:30:45 +01:00
co-authored by Claude Sonnet 5
commit 92c3ad3191
29 changed files with 2083 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Generated by talosctl gen config — contains private keys and client
# certificates. Never commit these.
_out/
talosconfig
# Downloaded by talosctl kubeconfig — grants full cluster access.
kubeconfig
# OS cruft
.DS_Store
+136
View File
@@ -0,0 +1,136 @@
# CLAUDE.md — how to help with this repo
This repo is the **GitOps Starter Pack**: a teaching project that takes
someone who has never touched Kubernetes, GitOps, Talos, or Argo CD to a
working, single-cluster GitOps setup they actually understand. When you
(Claude) are working in this repo, your primary job is **tutor first, doer
second**. Read this whole file before acting on any request here.
## Who you're talking to
An experienced IT professional: fluent with the command line, SSH,
networking, servers, and config files. **Not** fluent in Kubernetes,
GitOps, Talos, Argo CD, or any of their jargon — assume zero prior
exposure to this specific ecosystem, and never assume a term is obvious
just because it's common in cloud-native circles. They are not a beginner
at computers; don't explain what a terminal is. They may be a total
beginner at this stack; do explain what a Pod is, every time it's plausible
they haven't internalized it yet.
## How to teach in this repo
- **Why before how.** Before giving a command, say what problem it solves
and why this is the way to solve it — not just what to type.
- **Define every term on first use**, in the sentence you use it, even if
it's also in [`docs/glossary.md`](docs/glossary.md). Don't make them break
flow to go look it up.
- **Check understanding**, don't just narrate. After explaining something
non-trivial, ask a real question ("what do you think happens if you skip
the bootstrap step?") rather than "does that make sense?" — the latter
invites a reflexive yes.
- **Go in order.** The `docs/` files are numbered for a reason — each
assumes everything before it. If the user jumps ahead, it's fine to help,
but flag what they're skipping past and offer to backfill it.
- **Show real commands and real output**, never abstract description of
what a command "roughly" does. If you're not sure of the exact current
behavior, say so rather than guessing confidently.
- **"Ask Claude" checkpoints** are scattered through the docs on purpose —
when the user reaches one, treat it as an invitation to go deeper, not a
formality to acknowledge and move past.
## Guided Bootstrap Mode
When the user asks to be walked through setup — phrases like "walk me
through the checklist," "let's bootstrap this," "help me set this up
end to end" — drive [`docs/checklist.md`](docs/checklist.md) interactively:
1. Work through it **one item at a time**, in order, by phase.
2. Before each item, briefly say what it does and why (one or two
sentences — the full explanation lives in the doc it's drawn from; link
it, don't paste it wholesale).
3. Give the exact command, with any placeholders (`<NODE_IP>`,
`YOUR_GITHUB_USERNAME`) called out clearly so the user doesn't run it
unedited.
4. **Wait for the user to run it and report back** — the output, an error,
or just "done" — before moving to the next item. Don't dump the whole
phase at once.
5. If something fails, help debug it in place rather than plowing forward;
don't check a box that isn't actually true yet.
6. Periodically remind them of `docs/glossary.md` and the "Ask Claude"
checkpoints along the way — this mode shouldn't feel like a script being
read at them.
7. **Phase 5 (MetalLB) needs real information from the user, not
placeholders.** Before editing `apps/metallb/templates/ipaddresspool.yaml`
or `apps/demo/service.yaml`, actually ask: what does their LAN's subnet
look like, and what IP range does their router's DHCP server already
hand out? Pick a small unused slice outside that range for the MetalLB
pool, then get two more addresses from inside that pool — one for Argo
CD's UI, one for the demo app. Never fill these in with a guessed or
copied-from-the-docs address; a wrong one means real devices on their
network trying to share an IP.
If the user hasn't asked for guided mode, don't impose it — answer what
they actually asked, just keep the teaching posture from above.
## Keep `docs/network.md` current
[`docs/network.md`](docs/network.md) is the running log of every real IP
address this cluster hands out and what's reachable there. It only stays
useful if it's actually current, so treat updating it as part of the
change itself, not an afterthought:
- Whenever you (Claude) assign, learn, or change a real IP address for
something — walking the user through MetalLB's pool
([`docs/05-metallb.md`](docs/05-metallb.md)), patching `argocd-server`,
helping add a new app with a `LoadBalancer` Service — update the table in
`docs/network.md` in the same breath. Don't wait to be asked.
- Every row needs a full, correct URL — scheme, IP, and port — not just a
bare address. Include the port number unless it's the protocol's default
(80 for `http://`, 443 for `https://`). If you're not sure what port a
Service actually listens on, check its manifest (`spec.ports`) rather
than guessing.
- If you notice the file has drifted from reality (an address in it no
longer matches `kubectl get svc -A`, or a Service is missing from it),
fix it on the spot and mention that you did.
- This file tracks real, user-specific addresses — never fill a row with a
placeholder or an address you haven't actually confirmed is live.
## Suggested learning path
Point new users here first if they seem unsure where to start:
1. [`README.md`](README.md) — orientation
2. [`docs/00-what-is-all-this.md`](docs/00-what-is-all-this.md) — mental model, read before touching a terminal
3. [`docs/01-prerequisites.md`](docs/01-prerequisites.md) → [`08-growing-up.md`](docs/08-growing-up.md) — in order
4. [`docs/checklist.md`](docs/checklist.md) — condensed step-by-step reference once concepts are understood, or to drive Guided Bootstrap Mode
5. [`docs/glossary.md`](docs/glossary.md) — reference throughout, not read start to finish
## Working on the repo itself (not just teaching from it)
If asked to change manifests or docs rather than explain them:
- Keep manifests **real and working**, never illustrative pseudo-YAML —
this repo's whole value is that everything in it actually runs.
- Every non-obvious field gets a comment explaining *why* it's there, not
just what it is — match the style already in `apps/`, `talos/`, and
`argocd/`.
- Keep the moving parts minimal. Ingress, cert-manager, and secrets
management are intentionally **documented, not shipped** (see
[`docs/08-growing-up.md`](docs/08-growing-up.md)) — don't add them to
make the repo "more complete" without being asked. MetalLB is the one
exception, shipped deliberately because bare metal has no other way to
give a Service a real IP address — don't take that as license to add
other addons the same way without being asked.
- This repo is **public**. Never write a plaintext secret, credential, or
private key into any file here. If a change would need one, stop and
point to the Sealed Secrets / SOPS guidance in
`docs/08-growing-up.md` instead of improvising.
- New apps follow the existing `apps/<name>.yaml` + `apps/<name>/` pattern
— see [`docs/07-adding-new-apps.md`](docs/07-adding-new-apps.md) before
adding one. `apps/metallb/` is the reference example for a Helm-based
one.
- Every `apps/*.yaml` Application manifest needs its own `repoURL` fixed to
the user's fork — they don't inherit it from the app-of-apps. If you add
a new Application, make sure it carries the same placeholder-and-warning
pattern the others do, so it shows up in
`grep -rl YOUR_GITHUB_USERNAME .`.
+108
View File
@@ -0,0 +1,108 @@
# GitOps Starter Pack
A minimal, real, working GitOps setup for a single Kubernetes cluster —
built to teach, not just to run. If you've never touched Kubernetes,
GitOps, Talos, or Argo CD before, this is meant to get you from a bare
machine to a working cluster you actually understand, not just one you
copy-pasted into existence.
## Who this is for
You're comfortable with computers: the command line, SSH, servers,
networking, editing config files. You have **never** used Kubernetes,
GitOps, Argo CD, or Talos, and none of the jargon is familiar yet. This
repo assumes that, exactly. Every cloud-native term is defined in plain
English the first time it appears, and a full glossary lives at
[`docs/glossary.md`](docs/glossary.md).
## The mental model, briefly
Traditionally, you configure a server by SSHing in and changing things by
hand. Nothing records *why* it looks the way it does except your memory and
shell history. This repo takes a different approach:
1. **Kubernetes** runs your applications, and you tell it the state you
want ("one copy of this running") rather than the steps to get there —
it keeps making that true, continuously.
2. **Talos Linux** is the operating system underneath — built for nothing
but running Kubernetes, configured the same declarative way, with no
SSH or shell to log into.
3. **Argo CD** watches this Git repository and keeps the cluster matching
whatever it finds here. You stop running `kubectl apply` by hand; you
edit a file, commit, push, and Argo CD applies it for you.
4. **App-of-apps** is how this repo is organized so that adding a new app
later is "add a file," never a new procedure.
5. **MetalLB** gives apps in this cluster real IP addresses on your own
network — something a cloud provider would normally do for you, and
bare metal doesn't have on its own.
Git becomes the one place that's actually true. If you want the full
picture with diagrams before doing anything, start with
[`docs/00-what-is-all-this.md`](docs/00-what-is-all-this.md).
## What's in here
```
talos/ → Talos machine config + bootstrap guide (the OS layer)
argocd/ → Argo CD install + the one manifest you apply by hand
apps/ → Everything Argo CD manages from here on — demo app, MetalLB,
and wherever you add new apps
docs/ → The numbered, progressive guide you should actually read
```
## Prerequisites
- One machine (physical or VM) to become your cluster node
- `talosctl`, `kubectl`, and the `argocd` CLI installed locally
- A GitHub account, and your own fork of this repo
Full detail, with install commands: [`docs/01-prerequisites.md`](docs/01-prerequisites.md).
## The path through this repo
Read these in order. Each is short — one concept per file.
1. [`docs/00-what-is-all-this.md`](docs/00-what-is-all-this.md) — the mental model and architecture, before touching a terminal
2. [`docs/01-prerequisites.md`](docs/01-prerequisites.md) — install the tools, get a target machine ready
3. [`docs/02-talos-bootstrap.md`](docs/02-talos-bootstrap.md) — turn that machine into a Kubernetes node
4. [`docs/03-installing-argocd.md`](docs/03-installing-argocd.md) — install Argo CD into the cluster
5. [`docs/04-app-of-apps.md`](docs/04-app-of-apps.md) — apply the one manifest that hands control to Git
6. [`docs/05-metallb.md`](docs/05-metallb.md) — give apps real IP addresses on your network
7. [`docs/06-your-first-change.md`](docs/06-your-first-change.md) — edit a file, push, watch it deploy itself
8. [`docs/07-adding-new-apps.md`](docs/07-adding-new-apps.md) — add something of your own
9. [`docs/08-growing-up.md`](docs/08-growing-up.md) — next steps: more nodes, ingress, secrets management
10. [`docs/glossary.md`](docs/glossary.md) — every term, plain English, for reference throughout
11. [`docs/network.md`](docs/network.md) — the running log of every real IP address in your cluster, kept current as you go
Prefer a single condensed list you can work through step by step with
Claude, checking items off as you go? Use
[`docs/checklist.md`](docs/checklist.md) instead — see the next section.
## Learning this with Claude
This repo is meant to be read with an AI assistant open alongside it. The
root [`CLAUDE.md`](CLAUDE.md) primes Claude to act as a patient tutor for
this exact repo — teaching concepts in order, explaining *why* before
*how*, and checking your understanding rather than just handing you
commands. Throughout the docs, you'll see call-outs like:
> **Ask Claude:** ...
Take those. They mark places where pausing to ask a follow-up question
pays off more than plowing ahead.
If you'd rather be walked through the whole bootstrap interactively,
start a conversation with Claude in this repo and say something like
*"walk me through the checklist in docs/checklist.md"* — it will go
step by step, wait for your output at each one, and help you debug before
moving on.
## A note on scope
This repo deliberately does **not** ship ingress, TLS/cert-manager, or
secrets management in v1 — they're real next steps, documented in
[`docs/08-growing-up.md`](docs/08-growing-up.md), left out so the core
loop stays small enough to fully understand first. This repo is also
**public** — never commit plaintext secrets, credentials, or private keys
to it; see that same doc for how to handle secrets properly once you need
them.
+52
View File
@@ -0,0 +1,52 @@
# This is an Argo CD "Application" — not a Kubernetes-native concept, but a
# Custom Resource that Argo CD adds to the cluster (see docs/glossary.md:
# "CRD"). It tells Argo CD: "watch this path in this Git repo, and keep the
# cluster's demo namespace matching whatever you find there."
#
# You never apply this file yourself. The app-of-apps
# (argocd/app-of-apps/app-of-apps.yaml) watches this apps/ directory, and
# Argo CD applies every Application it finds here automatically — including
# this one. That's the whole "app-of-apps" trick: one Application manages a
# folder of other Applications.
#
# --- Manifest-based vs. Helm-based apps ---
# This app points at a plain folder of Kubernetes manifests (apps/demo/).
# An app can instead point at a Helm chart — including an umbrella chart
# whose Chart.yaml declares a `dependencies:` entry on a third-party chart
# (e.g. a database or ingress controller from a public chart repo). Argo CD
# detects a Helm chart automatically when it finds a Chart.yaml at the
# source path, resolves its dependencies, and syncs the rendered output just
# like any other app. See docs/07-adding-new-apps.md for a worked example —
# the demo app deliberately stays plain manifests so the GitOps loop is easy
# to read end to end with no templating in the way.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: demo
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/YOUR_GITHUB_USERNAME/k8s-starter-pack.git
targetRevision: main
path: apps/demo
destination:
server: https://kubernetes.default.svc
namespace: demo
syncPolicy:
automated:
# prune: delete cluster objects whose manifest was removed from Git.
# selfHeal: if someone changes the cluster by hand (e.g. `kubectl edit`),
# Argo CD reverts it back to match Git on the next reconciliation pass.
# Together these are what make Git the actual source of truth instead
# of just a suggestion.
prune: true
selfHeal: true
syncOptions:
# Creates the `demo` namespace automatically if it's missing, instead
# of failing sync. We also declare it explicitly in namespace.yaml for
# visibility, so this is a safety net more than a requirement here.
- CreateNamespace=true
+27
View File
@@ -0,0 +1,27 @@
# A ConfigMap is just a bag of key/value data — in this case, a small HTML
# file — that lives in the cluster as its own object, separate from the
# Pod that uses it. We mount it into the nginx container below instead of
# baking the page into a custom container image.
#
# Why this matters for the GitOps story: this is the file you'll edit in
# docs/06-your-first-change.md. Change the text here, commit, push, and
# watch Argo CD notice the difference in Git and push it into the cluster
# for you — no `kubectl` command involved. That's the whole loop, proven
# with one line of HTML.
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-page
namespace: demo
data:
index.html: |
<!doctype html>
<html>
<head><title>It works!</title></head>
<body style="font-family: sans-serif; text-align: center; margin-top: 10%;">
<h1>It works!</h1>
<p>This page is being served from a Kubernetes cluster that Argo CD
deployed straight from Git. Edit <code>apps/demo/configmap.yaml</code>,
commit, push, and refresh this page.</p>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
# A Deployment describes a desired state ("I want 1 copy of nginx running,
# serving this ConfigMap's index.html") and hands it to Kubernetes, whose
# job is to continuously make reality match that description. If the Pod
# crashes, Kubernetes restarts it. If the node dies, Kubernetes (given more
# than one node — see docs/08-growing-up.md) reschedules it elsewhere.
# You never tell Kubernetes "start a container" directly; you describe the
# end state and it does the work. This is what "declarative" means.
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
namespace: demo
spec:
replicas: 1
selector:
# Tells the Deployment which Pods belong to it. Must match
# spec.template.metadata.labels below, or Kubernetes won't know
# this Deployment owns the Pods it creates.
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
volumeMounts:
# Mounts the ConfigMap's index.html over nginx's default page.
- name: page
mountPath: /usr/share/nginx/html
volumes:
- name: page
configMap:
name: demo-page
+13
View File
@@ -0,0 +1,13 @@
# A Namespace is a way to group related resources together inside the
# cluster, like a folder. It doesn't do much on its own — its real job is
# letting you keep "demo" separate from other apps you add later, so you can
# manage, view, and delete them independently (e.g. `kubectl -n demo get pods`).
#
# We create this explicitly, as its own file, rather than letting Argo CD
# invent it implicitly. That way it shows up in Git like everything else —
# if you ever wonder "why does this namespace exist?", the answer is always
# "because a file in this repo says so."
apiVersion: v1
kind: Namespace
metadata:
name: demo
+32
View File
@@ -0,0 +1,32 @@
# A Service gives a stable way to reach a set of Pods, even as individual
# Pods are created and destroyed and get new internal IP addresses over
# time. This Service selects any Pod labelled app: demo (see
# deployment.yaml) and forwards traffic to it.
#
# type: LoadBalancer asks the cluster for a real, dedicated IP address that
# anything on your network can reach directly. On a cloud provider that
# would provision an actual cloud load balancer; on bare metal there's
# nothing to provision it, which is exactly the gap MetalLB
# (apps/metallb.yaml) fills — it watches for LoadBalancer Services and
# hands them an address from the pool configured in
# apps/metallb/templates/ipaddresspool.yaml. Until that pool exists, this
# Service's address will show as <pending>; see docs/05-metallb.md.
apiVersion: v1
kind: Service
metadata:
name: demo
namespace: demo
annotations:
# Requests this specific address from MetalLB's pool, so it stays
# predictable across rebuilds instead of getting whatever's next free.
# Must fall inside the range in
# apps/metallb/templates/ipaddresspool.yaml, or MetalLB will never
# satisfy the request.
metallb.universe.tf/loadBalancerIPs: "192.168.1.241" # EDIT ME
spec:
type: LoadBalancer
selector:
app: demo
ports:
- port: 80
targetPort: 80
+41
View File
@@ -0,0 +1,41 @@
# MetalLB, installed like any other app: an Application pointing at
# apps/metallb, a Helm chart (see apps/metallb/Chart.yaml) that both pulls
# in the upstream MetalLB chart AND carries our own IPAddressPool and
# L2Advertisement as extra templates (apps/metallb/templates/). Once this
# syncs, the cluster can hand out real IP addresses to Services of type
# LoadBalancer — something bare-metal Kubernetes doesn't do on its own
# (that's normally a cloud provider's job). See docs/05-metallb.md for the
# full explanation and the setup questions you'll need to answer before
# this is actually useful.
#
# Everything — MetalLB's own resources (including its CRDs) and our two
# custom resources that depend on them — comes from this one Application,
# in one sync. The ordering that makes that safe lives on the individual
# resources in apps/metallb/templates/, via the
# argocd.argoproj.io/sync-wave annotation: MetalLB's chart resources apply
# at the default wave ("0"), and our IPAddressPool/L2Advertisement are
# marked wave "1" so Argo CD waits for MetalLB to be synced and healthy
# (CRDs registered, controller and speaker running) before applying them.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: metallb
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/YOUR_GITHUB_USERNAME/k8s-starter-pack.git
targetRevision: main
path: apps/metallb
destination:
server: https://kubernetes.default.svc
namespace: metallb-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+21
View File
@@ -0,0 +1,21 @@
# An "umbrella" chart: it declares a dependency on the upstream MetalLB
# chart below, AND carries two templates of its own
# (apps/metallb/templates/) — the IPAddressPool and L2Advertisement that
# configure it. Argo CD detects this as a Helm source because a Chart.yaml
# exists at this path, resolves the dependency, and syncs the combined
# rendered output as one Application — exactly like any other app in
# apps/. This is the real, working example of the Helm-based app pattern
# mentioned in docs/07-adding-new-apps.md: use it as a template if you want
# to bring in another third-party chart later.
apiVersion: v2
name: metallb-app
description: Installs MetalLB from the upstream chart, for this repo's bare-metal LoadBalancer support
version: 0.1.0
dependencies:
- name: metallb
# A version *range* rather than an exact pin, so this doesn't silently
# go stale. For a fully reproducible install, check
# https://artifacthub.io/packages/helm/metallb/metallb for the current
# release and pin an exact version here instead.
version: ">=0.14.3"
repository: https://metallb.github.io/metallb
+29
View File
@@ -0,0 +1,29 @@
# Tells MetalLB which IP addresses it's allowed to hand out to Services.
# Pick a small range on your LAN that nothing else will ever assign —
# especially your router's DHCP server. MetalLB doesn't coordinate with
# your router or DHCP server at all; it just answers network requests for
# whatever address a Service claims from this range. If it overlaps your
# DHCP range, you'll eventually get two devices claiming the same IP.
#
# >> Ask Claude to help you pick this range before editing it — it needs
# to know your LAN's subnet and DHCP range, which it can't guess.
# A typical home LAN answer looks like "my router hands out
# 192.168.1.100–192.168.1.199, so let's use 192.168.1.240–192.168.1.250."
#
# This file lives under apps/metallb/templates/ alongside MetalLB's own
# Helm-installed resources, rather than as a separate app, because it
# depends on the CRD that chart installs — Kubernetes needs to know what
# an IPAddressPool *is* before it can accept one. The sync-wave annotation
# is what keeps that order correct: Argo CD applies wave "0" (this chart's
# own resources, including the CRDs) first, waits for it to be healthy,
# then applies wave "1" (this file and l2advertisement.yaml).
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
addresses:
- 192.168.1.240-192.168.1.250 # EDIT ME — replace with your chosen range
@@ -0,0 +1,19 @@
# L2Advertisement is what actually makes addresses from the pool reachable:
# it tells MetalLB to answer ARP requests for whichever address a Service
# was given, from whichever node currently hosts that Service. This is
# "Layer 2 mode" — the simplest of MetalLB's two modes, and the right
# choice here since it needs no cooperation from your router (the
# alternative, BGP mode, does — worth knowing exists if you outgrow a
# single L2 segment, not needed for this setup).
# Same sync-wave reasoning as ipaddresspool.yaml — deferred to wave "1" so
# it applies after MetalLB's own chart resources are up.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
ipAddressPools:
- default-pool
+9
View File
@@ -0,0 +1,9 @@
# Values for the upstream MetalLB chart, nested under `metallb:` (the
# dependency's name in Chart.yaml — Helm nests subchart values under the
# subchart's name automatically). Empty by default: MetalLB's own chart
# defaults are sane for a small single-cluster setup. Add overrides here if
# you ever need them, e.g.:
#
# metallb:
# speaker:
# tolerations: []
+133
View File
@@ -0,0 +1,133 @@
# Installing Argo CD — command reference
Companion to [`docs/03-installing-argocd.md`](../docs/03-installing-argocd.md)
and [`docs/04-app-of-apps.md`](../docs/04-app-of-apps.md). Read those for the
why; this is the exact commands.
Run these once `kubectl get nodes` shows your node `Ready` (see
[`talos/README.md`](../talos/README.md)).
## 1. Install Argo CD
```sh
kubectl create namespace argocd
kubectl apply --server-side -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
```
`--server-side` matters here, not just as a nice-to-have: one of Argo CD's
CRDs (`applicationsets.argoproj.io`) is large enough that a normal
(client-side) `kubectl apply` fails outright — it tries to stash the whole
manifest into an annotation for its 3-way-merge bookkeeping, and that
annotation hits Kubernetes' size limit. Server-side apply has the API
server do that diffing instead, so this limit doesn't apply.
This is an **imperative** install: a plain `kubectl apply` of a manifest
bundle that the Argo CD project maintains, rather than a Helm chart or
operator. It's the officially documented quickstart path, and it means
Argo CD itself isn't part of your GitOps loop — you install it once, by
hand, and *it* then manages everything else declaratively. Bootstrapping
has to start somewhere outside the loop; this is that starting point.
The `stable` in that URL is a moving tag maintained by the Argo CD project
that always points at the latest stable release — convenient here, but not
reproducible: what it installs today may differ from what it installs in
six months. For a production setup, pin an explicit version instead, e.g.
`.../v2.13.0/manifests/install.yaml` — see [releases](https://github.com/argoproj/argo-cd/releases)
for current tags.
Wait for everything to come up:
```sh
kubectl -n argocd wait --for=condition=Available --timeout=300s deployment --all
```
## 2. Get the initial admin password
```sh
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d; echo
```
This secret is auto-generated on first install. Save the password
somewhere; you'll change it in a moment.
## 3. Reach the UI and CLI
Argo CD doesn't expose itself outside the cluster by default. For this
learning setup, forward its port to your machine:
```sh
kubectl -n argocd port-forward svc/argocd-server 8080:443
```
Leave that running in its own terminal. In another terminal:
```sh
argocd login localhost:8080 --username admin --password <password-from-above> --insecure
```
`--insecure` here means "skip TLS certificate verification" — the server's
certificate is self-signed by default and there's nothing on the other end
but your own port-forward, so this is fine for local access. It is **not**
something to carry into a setup reachable over the real network.
Open `https://localhost:8080` in a browser to see the same thing visually.
Optional but recommended — replace the initial password:
```sh
argocd account update-password
```
## 4. Hand the cluster to Git
[`app-of-apps/app-of-apps.yaml`](app-of-apps/app-of-apps.yaml) is the one
manifest in this whole repo you apply by hand — everything after it, Argo
CD applies for you. First, every Application manifest needs its own
`repoURL` pointed at your fork (they don't inherit it from each other):
```sh
grep -rl YOUR_GITHUB_USERNAME .
```
Edit `spec.source.repoURL` in each file that command finds, commit, and
push — the child Applications are read from Git, not your local disk, so
this has to land in your fork before the next step. Then:
```sh
kubectl apply -f argocd/app-of-apps/app-of-apps.yaml
```
Watch it take over:
```sh
argocd app get app-of-apps
argocd app list
```
You should see `app-of-apps` sync, then `demo` and `metallb` Applications
appear underneath it, then sync themselves. See
[`docs/04-app-of-apps.md`](../docs/04-app-of-apps.md) for what's happening
here and [`docs/checklist.md`](../docs/checklist.md) for the full guided
path.
## 5. Give the UI a real address (MetalLB)
Once `metallb` (above) is `Synced`/`Healthy` and you've configured its
address pool (see [`docs/05-metallb.md`](../docs/05-metallb.md)), you can
retire the port-forward from step 3 and give Argo CD's UI a permanent
address instead. This one change is applied directly, not through Git —
Argo CD's own install isn't managed by this repo's GitOps loop:
```sh
kubectl -n argocd patch svc argocd-server --type merge -p \
'{"spec":{"type":"LoadBalancer"},"metadata":{"annotations":{"metallb.universe.tf/loadBalancerIPs":"<ARGOCD_IP>"}}}'
```
```sh
kubectl -n argocd get svc argocd-server
```
Once `EXTERNAL-IP` shows your chosen address, `https://<that-address>`
works directly, from any machine on your network — no port-forward
required.
+47
View File
@@ -0,0 +1,47 @@
# The app-of-apps. This is the ONE manifest in this entire repo that you
# apply by hand, one time, with `kubectl apply -f`. It's the seed: after
# this exists, Argo CD takes over and everything else in the apps/ folder
# gets applied automatically, because this Application's job is to watch
# that folder.
#
# Before applying this file, edit spec.source.repoURL below to point at YOUR
# fork of this repo (Argo CD needs to clone it, so it must be a real URL it
# can reach — not this template's URL).
#
# >> Ask Claude: "why does the app-of-apps live in argocd/app-of-apps
# instead of inside apps/ with everything else?" before moving on — the
# answer (avoiding a directory managing itself) is worth understanding.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-of-apps
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/YOUR_GITHUB_USERNAME/k8s-starter-pack.git
targetRevision: main
path: apps
directory:
# recurse: false is deliberate. apps/ contains both Application
# manifests (apps/demo.yaml) AND subfolders full of the actual
# workload manifests they point to (apps/demo/*.yaml). Without this,
# Argo CD would try to apply everything inside apps/demo/ directly
# under the app-of-apps too — duplicating work the demo Application
# already does, and blurring which Application actually owns what.
# recurse: false means "only look at files directly in apps/, ignore
# subfolders" — so the app-of-apps only ever sees the Application
# manifests, never the workloads themselves.
recurse: false
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+149
View File
@@ -0,0 +1,149 @@
# 00 — What is all this?
You're comfortable with servers, SSH, config files, and the command line.
You've probably never touched Kubernetes. This doc gives you the mental
model before you type a single command — everything after this builds on
it.
## The problem this stack solves
Say you run a few services on a server. The traditional way: SSH in,
install things, edit config files by hand, restart services, and remember
what you did (or don't, and regret it later). It works, but nothing
records *why* the server looks the way it does — the truth lives only on
that one machine, in your head, and in shell history if you're lucky.
This stack solves that by making a **Git repository** the single source of
truth for "what should be running," and using software to continuously make
the real cluster match it. You stop making changes by hand. You edit a file,
push it, and a robot applies it for you — the same way every time, with a
record of every change in `git log`.
## The five pieces, in plain terms
**Kubernetes** is software that runs on one or more machines and manages
containers for you: starts them, restarts them if they crash, and moves
them to a different machine if one dies (once you have more than one — see
[`docs/08-growing-up.md`](08-growing-up.md)). You describe the end state you
want ("run one copy of this"); Kubernetes' job is to make that true and keep
it true. This is what people mean by **declarative**: you state the
destination, not the steps to get there.
**Talos Linux** is the operating system your machine runs. It's built for
exactly one purpose — running Kubernetes — and nothing else. There's no SSH,
no shell, no package manager to fiddle with by hand. You configure it by
sending it a config file over the network (a "machine config"), the same
declarative idea as Kubernetes itself, just one layer lower. Less to
install, less to secure, less that can drift out of a known state.
**Argo CD** is the piece that watches a Git repository — this one — and
continuously makes the cluster match whatever it finds there. This is
called **GitOps**: Git isn't just where your config *lives*, it's where
change *happens*. You don't run `kubectl apply` for day-to-day changes;
you edit a file, commit, push, and Argo CD does the applying.
**App of apps** is the pattern this repo uses to organize what Argo CD
manages. Rather than one Application resource pointing at everything, one
Application — the app-of-apps — watches a folder (`apps/`) full of *other*
Application definitions, each pointing at its own small piece. Add a new
app by adding a new file — see
[`docs/07-adding-new-apps.md`](07-adding-new-apps.md).
**MetalLB** solves a specific problem: on a cloud provider, asking for a
public IP address for a service is one API call away; on your own bare
metal machine, nothing hands those out automatically. MetalLB runs inside
the cluster and fills that gap, so apps here can get a real, dedicated IP
address on your network instead of an awkward fixed port. It's pre-installed
in this repo, not a "next step" — see [`docs/05-metallb.md`](05-metallb.md).
## Architecture — the shape of what you'll build
```
┌─────────────────────────┐
│ Git repository (this │
│ repo, on GitHub) │
│ │
│ apps/demo.yaml │
│ apps/demo/*.yaml │──┐
│ apps/metallb.yaml │ │ watched by
│ apps/metallb/* │ │
└─────────────────────────┘ │
▼
┌───────────────────┐
│ Argo CD │ runs inside the cluster
│ (installed once, │
│ imperatively) │
└─────────┬──────────┘
│ applies manifests
▼
┌─────────────────────────────┐
│ Talos Linux node │
│ (control plane + worker, │
│ single node) │
│ │
│ ┌────────────┐ ┌──────────┐ │
│ │demo │ metallb- │ │
│ │namespace: │ system: │ │
│ │nginx Pod ⇄ │ hands out │ │
│ │Service │ a real IP │ │
│ │(LoadBalancer)◄─address to │ │
│ │ │ that Service│ │
│ └────────────┘ └──────────┘ │
└─────────────────────────────┘
```
Everything below the Git repo is a *consequence* of what's in Git. If the
box labelled "Talos Linux node" burned to the ground and you rebuilt it from
scratch, replaying this repo against it would recreate everything except the
raw OS bootstrap (docs/02), the one-time Argo CD install (docs/03), and the
handful of network-specific addresses you choose in docs/05 — all
deliberately kept outside the loop, since something has to start it and
nothing in Git can know your LAN's IP layout for you.
## The reconciliation loop
This is the mechanism that makes GitOps actually work, not just a nice
diagram. Argo CD repeats this forever, by default every few minutes (and
instantly, if you ask it to):
```
┌────────────────────────────────────────────────┐
│ │
│ 1. Read desired state from Git │
│ │ │
│ ▼ │
│ 2. Read actual state from the cluster │
│ │ │
│ ▼ │
│ 3. Diff them │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ no diff diff found │
│ │ │ │
│ │ ▼ │
│ │ 4. Apply the change so the │
│ │ cluster matches Git │
│ │ │ │
│ └────┬─────┘ │
│ ▼ │
│ wait, then repeat │
│ │
└────────────────────────────────────────────────┘
```
"Reconciliation" just means step 3 and 4: noticing a difference between what
should exist and what does, and fixing it. This loop is also what makes
Argo CD self-healing — if someone changes the cluster by hand (`kubectl edit`
during an emergency, say), the very next loop notices the drift from Git and
reverts it. Git wins, always. That's the whole point.
> **Ask Claude:** before moving on, try asking "why does Git winning matter —
> what goes wrong without it?" It's worth being able to answer in your own
> words before you start building.
## What's next
Continue to [`docs/01-prerequisites.md`](01-prerequisites.md), or jump
straight to [`docs/checklist.md`](checklist.md) if you'd rather work through
a condensed step-by-step list with Claude alongside you.
+112
View File
@@ -0,0 +1,112 @@
# 01 — Prerequisites
Everything here targets **amd64** (standard 64-bit x86 — a spare PC, mini
PC, or an amd64 VM). If your hardware is arm64 (Raspberry Pi, Apple Silicon
VM), the tools below all publish arm64 builds too; swap the architecture in
whichever install command you use and the rest of this guide is unchanged.
## 1. A target machine
You need one machine to become your cluster node — a spare physical PC, a
mini PC, or a VM (VirtualBox, UTM, Proxmox, or any hypervisor that can boot
from an ISO). Minimum realistic spec: 2 CPU cores, 4GB RAM, 20GB disk.
It needs to be reachable over the network from your everyday machine (the
one you'll run `talosctl` and `kubectl` from), and it needs a stable IP
address — see the note about DHCP reservations in
[`talos/README.md`](../talos/README.md).
You do **not** need to install an OS on it yet. That's what the next doc
covers — Talos gets installed by booting a special ISO and sending it
configuration over the network, not through a normal installer.
Download the Talos amd64 ISO from the
[Talos releases page](https://github.com/siderolabs/talos/releases) (look
for `metal-amd64.iso` under the latest release) and boot your target machine
from it. Leave it sitting at the boot screen — you'll configure it in
[`docs/02-talos-bootstrap.md`](02-talos-bootstrap.md).
> **Ask Claude:** if you're not sure whether to use a VM or spare physical
> hardware, ask — the tradeoffs (snapshots and easy resets vs. more
> realistic networking) are worth five minutes of discussion before you
> commit.
## 2. talosctl
The CLI you use to configure and manage Talos nodes.
**macOS (Homebrew):**
```sh
brew install siderolabs/tap/talosctl
```
**Linux:**
```sh
curl -sL https://talos.dev/install | sh
```
Verify:
```sh
talosctl version --client
```
## 3. kubectl
The standard Kubernetes CLI — you'll use it constantly, for this and every
future Kubernetes cluster you touch.
**macOS (Homebrew):**
```sh
brew install kubectl
```
**Linux:** follow the
[official install instructions](https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/)
for your distribution.
Verify:
```sh
kubectl version --client
```
## 4. argocd CLI
The command-line client for Argo CD — used mainly to log in and inspect
Applications; most day-to-day work happens through Git, not this CLI.
**macOS (Homebrew):**
```sh
brew install argocd
```
**Linux:**
Download the `argocd-linux-amd64` binary from the
[Argo CD releases page](https://github.com/argoproj/argo-cd/releases), then:
```sh
chmod +x argocd-linux-amd64
sudo mv argocd-linux-amd64 /usr/local/bin/argocd
```
Verify:
```sh
argocd version --client
```
## 5. A GitHub account and a fork of this repo
This whole system works by Argo CD reading a Git repository you control.
Fork this repo (or create a new repo from it) on GitHub, and clone *your*
copy — not the original — to work from. Every command later in this guide
that references a repo URL means your fork's URL.
## Checklist
- [ ] Target machine chosen, network-reachable, IP address noted
- [ ] Talos ISO downloaded, machine booted from it and idle at the boot screen
- [ ] `talosctl version --client` works
- [ ] `kubectl version --client` works
- [ ] `argocd version --client` works
- [ ] You have your own fork of this repo cloned locally
Once all of these are true, continue to
[`docs/02-talos-bootstrap.md`](02-talos-bootstrap.md).
+72
View File
@@ -0,0 +1,72 @@
# 02 — Bootstrapping Talos
At the end of this doc, you'll have a single-node Kubernetes cluster,
running nothing but Kubernetes itself. No Argo CD yet — that's next.
## Why Talos, specifically
Most guides have you install a general-purpose Linux distribution, then
install Kubernetes on top by hand or with a tool like kubeadm — meaning
there's a whole normal OS underneath, with a package manager, an SSH
daemon, and every other thing that can drift out of a known-good state or
need patching. Talos throws that away: it's an OS whose only job is running
Kubernetes. There's no SSH, no shell to log into, no packages to manage —
you configure it entirely by sending it a **machine config** file over the
network (the `talosctl` commands below), and it applies that config the
same declarative way Kubernetes applies your app manifests. Less surface
area, less to secure, less to keep in sync by hand — the same philosophy as
the rest of this stack, one layer lower.
## Single node, on purpose
A "real" Kubernetes cluster usually has multiple nodes — some running the
**control plane** (the brain: API server, scheduler, the cluster's
database) and others as **workers** (running your actual apps), so that
losing one machine doesn't take the whole cluster down. This repo starts
with exactly one node acting as both, because that's the cheapest way to
learn the concepts that matter for GitOps without needing multiple
machines. [`docs/08-growing-up.md`](08-growing-up.md) covers adding more
nodes once you're comfortable — the config here is deliberately compatible
with that path, not a dead end.
## Steps
Full exact commands live in [`talos/README.md`](../talos/README.md) — read
that alongside this doc; this section explains what's happening at each
stage, that one has copy-paste blocks.
1. **Generate machine configuration.** `talosctl gen config` produces
Talos's config files for your cluster, including
[`talos/patch-controlplane.yaml`](../talos/patch-controlplane.yaml) —
read that file, it's short and explains itself — which lets your one
node run workloads as well as the control plane.
2. **Apply the config to your node.** The node, freshly booted from the
Talos ISO, is sitting in "maintenance mode" with no configuration.
`talosctl apply-config` sends it the config generated in step 1. The
node reboots, installs Talos to its disk, and comes up running for real.
3. **Bootstrap etcd.** `talosctl bootstrap` initializes the cluster's data
store. This is a one-time, one-node-ever operation — even in a
multi-node cluster you only ever bootstrap once.
4. **Retrieve your kubeconfig.** `talosctl kubeconfig` downloads the
credentials you'll use to talk to Kubernetes with `kubectl` — the
Kubernetes equivalent of the `talosconfig` you got in step 1.
5. **Verify.** `kubectl get nodes` should show one node, `Ready`.
> **Ask Claude:** "what's the difference between a kubeconfig and a
> talosconfig, and why are they separate files?" is a good one to ask once
> you've got both — the split trips people up the first time.
## Checklist
- [ ] `_out/controlplane.yaml`, `worker.yaml`, and `talosconfig` generated
- [ ] Config applied to the node, node has rebooted and installed Talos
- [ ] `talosctl bootstrap` run exactly once
- [ ] kubeconfig retrieved
- [ ] `kubectl get nodes` shows one `Ready` node
Once your node is `Ready`, continue to
[`docs/03-installing-argocd.md`](03-installing-argocd.md).
+58
View File
@@ -0,0 +1,58 @@
# 03 — Installing Argo CD
Your cluster is alive but empty — it can run containers, but nothing is
telling it to run anything yet. This doc installs Argo CD, the piece that
watches Git and does that telling from now on.
## Why this one step is imperative
Everything else in this repo — every app, every future addition — gets
deployed *by* Argo CD, driven from Git. Argo CD itself is the one exception:
something has to exist in the cluster *before* it can start reading Git, so
installing it can't itself be a GitOps-managed step. This is normal, and
every GitOps setup has some version of this same bootstrap moment. We
install it the straightforward way the Argo CD project documents — one
`kubectl apply` of their maintained manifest bundle — rather than adding an
extra tool (Helm, an operator) just to install the GitOps tool itself.
## What you're installing
Argo CD is itself a set of things running inside your cluster: an API
server, a piece that watches Git repos, a piece that computes diffs against
the live cluster, and a UI. You're about to install all of it with one
command, into its own `argocd` namespace, so it stays cleanly separated
from the apps it will go on to manage.
## Steps
Exact commands: [`argocd/README.md`](../argocd/README.md). Summary of what
each stage does:
1. **Install.** `kubectl apply` the official manifest bundle into a new
`argocd` namespace. This creates Argo CD's own pods *and* a handful of
CRDs (see [`docs/glossary.md`](glossary.md)) — the most important being
`Application`, the resource type you'll use for every app from here on,
including the demo app and the app-of-apps.
2. **Get the initial admin password.** Auto-generated on first install and
stored as a Kubernetes Secret.
3. **Reach it.** Port-forward the Argo CD UI/API to your local machine and
log in with the `argocd` CLI (and optionally, a browser).
4. **Apply the app-of-apps.** This is the moment the GitOps loop actually
starts — see [`docs/04-app-of-apps.md`](04-app-of-apps.md) next.
> **Ask Claude:** "why is a port-forward good enough for now, and what
> would I need instead to reach this Argo CD from somewhere other than my
> own machine?" — a preview of the Ingress topic in
> [`docs/08-growing-up.md`](08-growing-up.md).
## Checklist
- [ ] Argo CD pods all `Running`/`Ready` in the `argocd` namespace
- [ ] Initial admin password retrieved
- [ ] Logged in via `argocd login` (CLI) and/or the UI in a browser
Once you're logged in, continue to
[`docs/04-app-of-apps.md`](04-app-of-apps.md).
+119
View File
@@ -0,0 +1,119 @@
# 04 — The app-of-apps pattern
This is the last manual step. After this, Git drives everything.
## The idea
An Argo CD `Application` is a resource that says "watch this path in this
Git repo, and keep the cluster matching what's there." The simplest
possible setup is one Application per app you want to run — but then every
new app means one more manual `kubectl apply`, which quietly puts you back
to making changes by hand.
**App-of-apps** fixes this by making Applications themselves something Argo
CD manages. One Application — the app-of-apps — watches a folder
([`apps/`](../apps)) full of *other* Application manifests. Add a new app by
adding a new file to that folder and pushing it — the app-of-apps notices
the new file and creates the Application it describes, which in turn
deploys the app. No `kubectl apply` required past this point, ever, for a
new app.
```
argocd/app-of-apps/app-of-apps.yaml (you apply this ONE file, by hand, once)
│
│ watches apps/, non-recursively
├──────────────┬───────────────┐
▼ ▼ ▼
apps/demo.yaml apps/metallb.yaml (more, later — just add a file)
│ │
▼ ▼
apps/demo/*.yaml apps/metallb/* (the actual manifests / Helm chart)
```
Three layers, each watching the next. You only ever touch the bottom two —
the app-of-apps is set-and-forget. Notice `apps/metallb.yaml` is already
here — see [`docs/05-metallb.md`](05-metallb.md) right after this one.
## Why `apps/` isn't recursive
Look at [`argocd/app-of-apps/app-of-apps.yaml`](../argocd/app-of-apps/app-of-apps.yaml)
— specifically `directory.recurse: false`. The `apps/` folder contains both
Application manifests (`apps/demo.yaml`, `apps/metallb.yaml`) *and*
subfolders holding the real workload manifests those Applications point to
(`apps/demo/*.yaml`, `apps/metallb/*`). Non-recursive means the
app-of-apps only ever looks at files directly inside `apps/`, never inside
its subfolders — so it only ever sees Application definitions, and leaves
the actual workload manifests to the Application that owns them. Without
this, the app-of-apps would try to apply raw Deployments and Helm chart
files itself, duplicating and conflicting with what those Applications are
already doing.
## Every Application needs its own `repoURL`
Applications don't inherit `repoURL` from whatever applied them — each one
declares its own source repo independently. That means the placeholder
`https://github.com/YOUR_GITHUB_USERNAME/...` needs fixing in *every* file
that has it, not just the one you apply by hand:
```sh
grep -rl YOUR_GITHUB_USERNAME .
```
You should see three files: `argocd/app-of-apps/app-of-apps.yaml`,
`apps/demo.yaml`, and `apps/metallb.yaml`. Edit `spec.source.repoURL` in
each to point at your fork.
## Steps
1. Fix `repoURL` in all three files (above).
2. Commit and push — this part matters more than it looks: the
app-of-apps reads `apps/demo.yaml` and `apps/metallb.yaml` *from Git*,
not from your local edits, so they have to be pushed before you apply
anything.
```sh
git add argocd/app-of-apps/app-of-apps.yaml apps/demo.yaml apps/metallb.yaml
git commit -m "Point Applications at my fork"
git push
```
3. Apply the app-of-apps — the one manifest you apply by hand:
```sh
kubectl apply -f argocd/app-of-apps/app-of-apps.yaml
```
4. Watch it sync, then watch `demo` and `metallb` appear underneath it:
```sh
argocd app list
```
You should see `app-of-apps`, `demo`, and `metallb`, all `Synced` and
`Healthy` within a minute or two (MetalLB's own install takes a little
longer than the demo app).
5. Confirm the demo app is actually running:
```sh
kubectl -n demo get pods,svc
```
You'll notice the Service's `EXTERNAL-IP` column says `<pending>` —
that's expected. The demo app is a `LoadBalancer` Service, and nothing
hands those out real addresses on bare metal until MetalLB is
configured, which is exactly the next doc.
If `app-of-apps`, `demo`, and `metallb` all show `Synced`/`Healthy`, the
loop — Git → Argo CD → cluster — is already proven end to end; you just
can't see the demo app in a browser yet.
> **Ask Claude:** "walk me through exactly what happened, in order, from the
> moment I ran `kubectl apply -f argocd/app-of-apps/app-of-apps.yaml` to
> these three Applications existing." Making sure you can narrate that
> chain yourself is the real checkpoint for this doc — everything from
> here on is a variation on it.
## Checklist
- [ ] `repoURL` fixed in all three files listed above
- [ ] Fix committed and pushed
- [ ] `app-of-apps` applied and shows `Synced`/`Healthy`
- [ ] `demo` and `metallb` Applications appeared automatically, both
`Synced`/`Healthy`
- [ ] `kubectl -n demo get pods,svc` shows the demo app's Pod running
(Service `EXTERNAL-IP` still `<pending>` — expected for now)
Continue to [`docs/05-metallb.md`](05-metallb.md) to give the demo app —
and Argo CD's own UI — a real address.
+155
View File
@@ -0,0 +1,155 @@
# 05 — MetalLB: getting real IP addresses on bare metal
The app-of-apps you just applied ([`docs/04-app-of-apps.md`](04-app-of-apps.md))
already brought up `metallb` alongside `demo`. This doc explains what it
does, walks you through the one piece only you can decide (which IP
addresses to actually use), and finishes by giving Argo CD's own UI a real
address too.
## Why this is needed at all
On a cloud provider, a Kubernetes Service of `type: LoadBalancer` triggers
the cloud to provision an actual load balancer and hand your Service a
real, routable IP automatically. Bare metal — your Talos node, sitting on
your own network — has no such thing behind it. Without help, a
`LoadBalancer` Service on bare metal just sits with its address stuck at
`<pending>` forever.
**MetalLB** is that help: software that runs inside your cluster,
watches for `LoadBalancer` Services, and hands them a real address from a
range *you* configure — then makes that address actually reachable on your
network. It's the standard, widely-used answer to this exact gap, which is
why it's installed as a pre-installed app in this repo rather than left as
a "next step."
`apps/demo/service.yaml` is already `type: LoadBalancer` — that's what you
saw sitting at `<pending>` if you looked closely in the last doc. This doc
is what turns that into a real, reachable address.
## What's already running
**`apps/metallb.yaml`** → one Application, pointing at `apps/metallb`, a
Helm chart that does two things at once (see
[`apps/metallb/Chart.yaml`](../apps/metallb/Chart.yaml)):
- Pulls in the official upstream MetalLB chart as a dependency — this is
also the real, working example of a Helm-based app mentioned in
[`docs/07-adding-new-apps.md`](07-adding-new-apps.md).
- Carries two templates of its own
([`apps/metallb/templates/`](../apps/metallb/templates)): an
`IPAddressPool` and an `L2Advertisement` — MetalLB's own custom
resources, which tell it which addresses it's allowed to use and how to
advertise them. Right now these still contain **placeholder** IP
addresses that won't work on your network — that's what you're about to
fix.
Both the upstream chart and our two extra resources sync together as one
Application. Look at the `sync-wave` annotation on
[`apps/metallb/templates/ipaddresspool.yaml`](../apps/metallb/templates/ipaddresspool.yaml) —
it's what keeps this safe despite being one sync: MetalLB's own resources
(including its CRDs) apply first, and Argo CD waits for them to be healthy
before applying our two resources, which depend on those CRDs existing.
## Answer three questions before editing anything
> **Ask Claude:** literally ask — "help me pick MetalLB's IP range and the
> addresses for Argo CD and the demo app." This is the one part of this
> whole repo that depends entirely on *your* network, which nothing in
> this repo can know in advance. Have your router's admin page or DHCP
> settings handy; Claude will ask what your LAN's subnet is and what range
> your DHCP server already hands out, so the pool it suggests doesn't
> collide with anything.
You need three decisions:
1. **The pool range** — a handful of addresses (5–10 is plenty for this
repo) on your LAN's subnet that your DHCP server will never hand to
anything else. Commonly a slice just above or below your DHCP range,
e.g. if your router hands out `.100`–`.199`, something like
`.240`–`.250` is a safe, unused slice.
2. **Argo CD's UI address** — one address from that pool.
3. **The demo app's address** — a different address from that pool.
## Edit the placeholders
1. [`apps/metallb/templates/ipaddresspool.yaml`](../apps/metallb/templates/ipaddresspool.yaml)
— replace the `addresses` range with your chosen pool.
2. [`apps/demo/service.yaml`](../apps/demo/service.yaml) — replace the
`metallb.universe.tf/loadBalancerIPs` annotation with the demo app's
chosen address (must be inside the pool from step 1).
3. [`docs/network.md`](network.md) — record the pool range and the demo
app's address in the tables there (Argo CD's address goes in once you've
picked it below).
4. Commit and push all three:
```sh
git add apps/metallb/templates/ipaddresspool.yaml apps/demo/service.yaml docs/network.md
git commit -m "Configure MetalLB's address pool for my network"
git push
```
5. Force a sync rather than waiting for the next poll:
```sh
argocd app sync metallb demo
```
## Verify
```sh
kubectl -n demo get svc demo
```
`EXTERNAL-IP` should now show the address you chose instead of `<pending>`.
Visit `http://<that-address>` in a browser — same "It works!" page as
before, now on its own real IP instead of a NodePort.
```sh
kubectl -n metallb-system get ipaddresspools,l2advertisements
```
Confirms your pool and advertisement were applied as expected.
## Give Argo CD's UI a real address too
So far you've reached the Argo CD UI through `kubectl port-forward`
(see [`docs/03-installing-argocd.md`](03-installing-argocd.md)). Now that
MetalLB is running, you can give it a permanent address instead. Argo CD's
own install isn't managed through this repo's GitOps loop (it's the
imperative bootstrap step — see `docs/03`), so this one change is applied
directly rather than through Git:
```sh
kubectl -n argocd patch svc argocd-server --type merge -p \
'{"spec":{"type":"LoadBalancer"},"metadata":{"annotations":{"metallb.universe.tf/loadBalancerIPs":"<ARGOCD_IP>"}}}'
```
Replace `<ARGOCD_IP>` with the address you chose in step 2 above. Then:
```sh
kubectl -n argocd get svc argocd-server
```
Once `EXTERNAL-IP` shows your chosen address, visit
`https://<that-address>` directly — no port-forward needed from now on.
(You'll still hit the same self-signed certificate warning as before; see
[`docs/03-installing-argocd.md`](03-installing-argocd.md) for why that's
expected here.)
Update [`docs/network.md`](network.md) one more time with this address —
commit and push it. This one's a genuine exception to "everything's driven
by Git": the patch command above isn't a file in this repo, so this file is
the only place this address gets recorded at all.
> **Ask Claude:** "why did the demo app get its LoadBalancer IP through
> Git, but Argo CD's didn't?" — a good check that the boundary between
> "managed by this repo" and "the imperative bootstrap that starts it"
> has actually landed.
## Checklist
- [ ] Chosen a pool range, an Argo CD UI address, and a demo app address,
with Claude's help, that fit your actual network
- [ ] `apps/metallb/templates/ipaddresspool.yaml` edited to your real range
- [ ] `apps/demo/service.yaml` edited to your real demo address
- [ ] `docs/network.md` updated with the pool range and both addresses
- [ ] Committed, pushed, synced
- [ ] `kubectl -n demo get svc demo` shows a real `EXTERNAL-IP`, page loads in a browser
- [ ] `argocd-server` patched to `LoadBalancer`, reachable at its own address without port-forwarding
Continue to [`docs/06-your-first-change.md`](06-your-first-change.md).
+94
View File
@@ -0,0 +1,94 @@
# 06 — Your first change
Everything so far proved Argo CD can deploy what's in Git. This doc proves
the part that actually matters day to day: changing what's in Git and
watching the cluster follow, with no `kubectl apply` from you at all.
## Steps
1. Open [`apps/demo/configmap.yaml`](../apps/demo/configmap.yaml) and edit
the HTML — change the `<h1>` text to something of your own.
2. Commit and push, exactly like any other Git change:
```sh
git add apps/demo/configmap.yaml
git commit -m "Personalize the demo page"
git push
```
3. By default, Argo CD polls Git every three minutes, so your change will
show up on its own — but you can force it immediately:
```sh
argocd app sync demo
```
4. Watch it happen:
```sh
argocd app get demo
```
You'll see Argo CD detect that the live ConfigMap no longer matches
Git, and correct it.
5. Refresh `http://<your-demo-app-IP>` (the MetalLB address you gave it in
[`docs/05-metallb.md`](05-metallb.md)). Your new text should be there. Note
that nginx itself never restarted — Kubernetes noticed the mounted
ConfigMap changed and updated the file inside the running Pod's
filesystem automatically. (This is specific to volume-mounted
ConfigMaps; not every kind of change is this seamless — a change to the
Deployment's image, for instance, does trigger a Pod restart, by
design.)
Give it up to a minute if the page doesn't look different yet.
`argocd app get demo` shows the *ConfigMap object* updating right away,
but kubelet — the agent on the node that actually keeps a Pod's mounted
files in sync — only re-checks mounted ConfigMaps on its own periodic
cycle, not instantly. A blank refresh isn't a sign anything's wrong;
give it a moment and refresh again.
## What just proved itself
You changed a file, pushed it, and never touched the cluster directly. That
gap between "commit" and "running" being closed automatically, without you
doing the applying, *is* GitOps. Everything from here on — every future app
you add — works exactly this way: edit, commit, push, done.
## Try triggering self-healing
Optional, but worth seeing once. Reintroduce drift by hand:
```sh
kubectl -n demo patch configmap demo-page --type merge \
-p '{"data":{"index.html":"<h1>hand-edited, not from Git</h1>"}}'
```
Watch the ConfigMap *object* itself, not the browser, for this one — it's
the more reliable way to see the timing: Argo CD's self-heal typically
reverts a hand-edit within a second or two, which is faster than kubelet's
own mount-sync cycle usually gets around to reflecting a change in the
served page at all (see the note above). Check the object directly:
```sh
kubectl -n demo get configmap demo-page -o jsonpath='{.data.index\.html}'
```
Run that once right after the `patch` above and you may catch your
hand-edited text; run it again a couple of seconds later and it'll already
be back to what's in Git — with no action from you. That's `selfHeal:
true` from `apps/demo.yaml` in action. See
[`docs/00-what-is-all-this.md`](00-what-is-all-this.md#the-reconciliation-loop)
for the loop that just ran.
> **Ask Claude:** "if selfHeal is on, how would I ever safely debug
> something by changing it live in the cluster first?" — a real question
> people run into once this clicks, and worth understanding before you hit
> it for real.
## Checklist
- [ ] Edited `apps/demo/configmap.yaml`, committed, pushed
- [ ] Saw the change reflected in `argocd app get demo`
- [ ] Saw the new text in the browser
- [ ] (Optional) Watched self-healing revert a hand-made change
Continue to [`docs/07-adding-new-apps.md`](07-adding-new-apps.md) to add
something of your own.
+87
View File
@@ -0,0 +1,87 @@
# 07 — Adding a new app
This is the payoff for the app-of-apps structure: adding something new is
"drop a file here," not a special procedure.
## The pattern
Every app in this repo is two things sitting side by side in
[`apps/`](../apps):
```
apps/
demo.yaml ← the Application (tells Argo CD where to look)
demo/ ← the actual manifests (what gets deployed)
```
To add a new app called, say, `whoami`:
1. Create `apps/whoami/` and put its Kubernetes manifests in there — a
Deployment, a Service, whatever it needs. Use `apps/demo/` as a
template for structure and comment style.
2. Create `apps/whoami.yaml`, copied from [`apps/demo.yaml`](../apps/demo.yaml)
with the names changed: `metadata.name`, `spec.source.repoURL` (still
your fork — see [`docs/04-app-of-apps.md`](04-app-of-apps.md#every-application-needs-its-own-repourl)
for why every Application needs this set individually), `spec.source.path`
(→ `apps/whoami`), and `spec.destination.namespace`.
3. Commit and push both. That's it — no `kubectl apply`. The app-of-apps
(already watching `apps/` — see
[`docs/04-app-of-apps.md`](04-app-of-apps.md)) notices the new
`whoami.yaml`, creates the `whoami` Application, which in turn deploys
everything in `apps/whoami/`.
4. Confirm: `argocd app list` should show `whoami` alongside `demo` and
`metallb`.
## Manifest-based vs. Helm-based apps
`apps/demo/` is plain Kubernetes manifests — no templating, nothing to
render — which is why it's the easiest one to read start to finish while
you're still learning what each field does.
An app's folder can be a **Helm chart** instead. Argo CD detects this
automatically: if it finds a `Chart.yaml` at the source path, it treats
that app as a Helm chart rather than plain manifests. This matters most
when you want to pull in a *third-party* chart — a database, an ingress
controller, anything with a public Helm chart already maintained by
someone else — without copying their manifests into your repo by hand. You
declare it as a dependency and let Helm (via Argo CD) resolve it.
You don't have to imagine this one — it's already running.
[`apps/metallb/`](../apps/metallb) is exactly this pattern, for real: its
[`Chart.yaml`](../apps/metallb/Chart.yaml) declares a dependency on the
upstream MetalLB chart, and it even goes a step further by also carrying
its own templates ([`apps/metallb/templates/`](../apps/metallb/templates))
for two custom resources that configure MetalLB once it's installed —
useful to see if you ever need an app that's *mostly* someone else's chart
but with a couple of your own resources layered on top. See
[`docs/05-metallb.md`](05-metallb.md) for the full walkthrough, and
[`apps/metallb.yaml`](../apps/metallb.yaml) for the matching Application —
notice it's structurally identical to `apps/demo.yaml`; Argo CD figures out
it's a Helm chart on its own from the presence of `Chart.yaml`, no new
syntax required in the Application resource itself.
> **Ask Claude:** "walk me through how I'd bring in [some real chart you
> have in mind] as a Helm-based app in this structure" whenever you're
> ready to try one of your own — `apps/metallb/` is a real template to
> copy from.
If the new app has its own `LoadBalancer` Service (see
[`docs/05-metallb.md`](05-metallb.md)), pick its address from the free
addresses listed in [`docs/network.md`](network.md), and add a row for it
there once it's up — that file only stays useful if every app that gets an
address shows up in it.
## Checklist
- [ ] New app's manifests live in `apps/<name>/`
- [ ] New `apps/<name>.yaml` Application created, modeled on `demo.yaml`
- [ ] `repoURL` set to your fork, not left as a placeholder
- [ ] Pushed to Git
- [ ] `argocd app list` shows the new app, `Synced`/`Healthy`
- [ ] If it has a `LoadBalancer` Service, its address is recorded in `docs/network.md`
Continue to [`docs/08-growing-up.md`](08-growing-up.md) for what to add
next as this cluster grows beyond a learning setup.
+91
View File
@@ -0,0 +1,91 @@
# 08 — Growing up: what to add next
Everything up to here is deliberately minimal — enough to understand the
whole loop, nothing more. This doc is a map of the natural next steps, each
with why you'd want it and roughly what it involves. None of it is built
for you; each is its own project once you're ready.
## More nodes
Right now, one machine is both control plane and worker
(`allowSchedulingOnControlPlanes: true` in
[`talos/patch-controlplane.yaml`](../talos/patch-controlplane.yaml)). That's
a single point of failure: if that machine dies, the cluster is gone.
To grow beyond it:
1. Boot a second machine from the Talos ISO, same as the first.
2. Generate a **worker** config instead of a control-plane one — you
already have `_out/worker.yaml` from
[`docs/02-talos-bootstrap.md`](02-talos-bootstrap.md), unused until now.
3. `talosctl apply-config --insecure --nodes <NEW_NODE_IP> --file _out/worker.yaml`
4. `kubectl get nodes` should show two nodes shortly after.
For real resilience against a control-plane node failing (not just adding
worker capacity), you'd want an odd number of control-plane nodes — 3 is
the normal minimum — because etcd (the cluster's data store) uses majority
voting to agree on state, and majority voting needs an odd number to avoid
ties. That's a bigger step than this doc covers in detail; ask Claude when
you're actually ready to take it.
## Ingress
Right now, thanks to MetalLB ([`docs/05-metallb.md`](05-metallb.md)), the
demo app gets its own dedicated IP address
(see [`apps/demo/service.yaml`](../apps/demo/service.yaml)). That's a real
improvement over NodePort, but it doesn't scale past a handful of apps
either: every app still needs its own IP address out of a pool that's only
so big, and there's no way to route by hostname (`app1.example.com` vs
`app2.example.com` sharing one IP on port 443).
An **Ingress controller** is itself just another app you'd deploy the same
way as anything else in this repo (see
[`docs/07-adding-new-apps.md`](07-adding-new-apps.md)) — commonly as a
Helm-based app, since most ingress controllers are distributed that way,
same as MetalLB. It typically sits behind one single MetalLB
`LoadBalancer` Service (one IP, total, for every app), and you add an
`Ingress` resource per app instead of giving each one its own
`LoadBalancer` Service — trading "one IP per app" for "one IP, many
hostnames."
## cert-manager
Once you have Ingress and real hostnames, you'll want HTTPS. `cert-manager`
is the standard tool for this — it watches for certificate requests and
automatically obtains and renews them (commonly via Let's Encrypt). Also
just another app in this repo's structure once you get there.
## Secrets management
**This repo is public. Never commit a plaintext secret, credential, token,
or private key to it — anyone can read your Git history, forever, even if
you delete the file in a later commit.**
Kubernetes' own `Secret` resource does not encrypt anything — it just
base64-encodes data, which is not encryption, only encoding (trivially
reversible by anyone). None of the manifests in this repo need secrets yet,
which is exactly why this hasn't come up so far. When you add something
that does (a database password, an API key), you need one of:
- **[Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets)** —
encrypts a secret using a key that only your cluster holds, producing a
`SealedSecret` file that's safe to commit; the cluster decrypts it into a
normal Secret on the fly.
- **[SOPS](https://github.com/getsops/sops)** (often paired with Argo CD via
a plugin) — encrypts values inside a normal-looking YAML file using a key
from a system like age or a cloud KMS, decrypted at sync time.
Either is a reasonable choice; don't improvise your own approach. Ask
Claude to walk you through setting one up when you have a real secret to
manage — it's a short, well-trodden path once you're at that point.
> **Ask Claude:** if you're about to commit anything you're even slightly
> unsure about, paste it (or describe it) and ask first. Cheaper than a
> `git filter-branch` later.
## Where to go from here
You've now seen the whole shape: Talos underneath, Argo CD watching Git,
app-of-apps organizing what it manages. Everything in this doc is more of
the same pattern — a new app, a new file, a push. Pick whichever expansion
is actually useful to you and start there.
+90
View File
@@ -0,0 +1,90 @@
# Guided bootstrap checklist
A single-page, ordered checklist covering the entire path from bare
machine to a working GitOps loop. It's meant to be worked through **with
Claude**, one item at a time — ask Claude to walk you through this list,
and it will run each step with you, wait for your output, confirm it
worked, and only then move to the next one. See `CLAUDE.md` at the repo
root for how Claude is primed to do this.
Each item names the doc it's drawn from, in case you want the full
explanation before or after running it. Replace `<NODE_IP>`,
`YOUR_GITHUB_USERNAME`, and the MetalLB IP addresses with your real values
throughout — Claude will remind you if you paste a command still
containing a placeholder, and will ask you for the IP addresses directly
in Phase 5 rather than guessing them.
## Phase 1 — Prerequisites ([`01`](01-prerequisites.md))
- [ ] Target machine chosen, reachable on the network, with a stable
(ideally DHCP-reserved) IP address
- [ ] Talos amd64 ISO downloaded, machine booted from it, sitting at the
boot screen
- [ ] `talosctl version --client` runs successfully
- [ ] `kubectl version --client` runs successfully
- [ ] `argocd version --client` runs successfully
- [ ] Repo forked on GitHub and cloned locally as **your own copy**
## Phase 2 — Talos bootstrap ([`02`](02-talos-bootstrap.md), [`talos/README.md`](../talos/README.md))
- [ ] `talosctl gen config gitops-starter https://<NODE_IP>:6443 --output-dir _out --config-patch-control-plane @talos/patch-controlplane.yaml` run — `_out/controlplane.yaml`, `worker.yaml`, `talosconfig` exist
- [ ] `TALOSCONFIG` exported (or `talosctl config endpoint`/`node` set) to `_out/talosconfig`
- [ ] `talosctl apply-config --insecure --nodes <NODE_IP> --file _out/controlplane.yaml` run, node rebooted
- [ ] `talosctl bootstrap --nodes <NODE_IP>` run — **exactly once**
- [ ] `talosctl kubeconfig --nodes <NODE_IP> .` run, `KUBECONFIG` pointed at the result
- [ ] `kubectl get nodes` shows one node, `Ready`
## Phase 3 — Argo CD install ([`03`](03-installing-argocd.md), [`argocd/README.md`](../argocd/README.md))
- [ ] `kubectl create namespace argocd`
- [ ] `kubectl apply --server-side -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml`
- [ ] `kubectl -n argocd wait --for=condition=Available --timeout=300s deployment --all` completes
- [ ] Initial admin password retrieved
- [ ] `kubectl -n argocd port-forward svc/argocd-server 8080:443` running in its own terminal
- [ ] `argocd login localhost:8080 --username admin --password <pw> --insecure` succeeds
## Phase 4 — Hand the cluster to Git ([`04`](04-app-of-apps.md))
- [ ] `spec.source.repoURL` fixed in all three placeholder files: run
`grep -rl YOUR_GITHUB_USERNAME .` to find them
(`argocd/app-of-apps/app-of-apps.yaml`, `apps/demo.yaml`, `apps/metallb.yaml`)
- [ ] Edit committed and pushed to your fork's `main` branch
- [ ] `kubectl apply -f argocd/app-of-apps/app-of-apps.yaml`
- [ ] `argocd app list` shows `app-of-apps`, `Synced`/`Healthy`
- [ ] `argocd app list` shows `demo` and `metallb`, appeared automatically, both `Synced`/`Healthy`
- [ ] `kubectl -n demo get pods,svc` shows the demo app's Pod running
(Service `EXTERNAL-IP` still `<pending>` — expected for now)
## Phase 5 — MetalLB: real IP addresses ([`05`](05-metallb.md))
- [ ] **Ask Claude** to help pick: a MetalLB pool range, an address for
Argo CD's UI, and an address for the demo app — all based on your
actual LAN/DHCP setup
- [ ] `apps/metallb/templates/ipaddresspool.yaml` edited to your real pool range
- [ ] `apps/demo/service.yaml` edited to your real demo app address
- [ ] `docs/network.md` updated with the pool range and both addresses (Argo CD's added once patched, below)
- [ ] Committed, pushed, and synced (`argocd app sync metallb demo`)
- [ ] `kubectl -n demo get svc demo` shows a real `EXTERNAL-IP`; page loads at `http://<that-address>`
- [ ] `argocd-server` Service patched to `LoadBalancer` with your chosen Argo CD address (`kubectl -n argocd patch svc argocd-server ...`)
- [ ] Argo CD UI reachable at `https://<that-address>` with no port-forward running
## Phase 6 — Prove the loop ([`06`](06-your-first-change.md))
- [ ] Edited the `<h1>` text in `apps/demo/configmap.yaml`
- [ ] Committed and pushed
- [ ] `argocd app sync demo` (or waited for auto-poll) shows the change applied
- [ ] Refreshed the browser — new text visible
- [ ] (Optional) Triggered self-heal: hand-edited the ConfigMap with `kubectl patch`, confirmed Argo CD reverted it
## You're done when
Every box above is checked, you've watched a change you made in a text
editor show up in a browser without ever running `kubectl apply` yourself
for it, and you can explain in your own words what happened between "git
push" and the page updating. If you can't yet, that's the sign to ask
Claude for a walkthrough of whichever phase felt shaky before calling this
finished.
From here: [`07-adding-new-apps.md`](07-adding-new-apps.md) to add
something of your own, or [`08-growing-up.md`](08-growing-up.md) for the
bigger next steps (more nodes, ingress, secrets management).
+161
View File
@@ -0,0 +1,161 @@
# Glossary
Every term this repo uses, in plain English. If you hit a word in the docs
or a YAML comment that isn't here, ask Claude to add it.
**API server** — The part of Kubernetes every other piece (and every
`kubectl` command) talks to. It's the front door: all reads and writes to
the cluster's state go through it.
**Application (Argo CD)** — A Custom Resource (see CRD, below) that Argo CD
adds to Kubernetes. It tells Argo CD "watch this path in this Git repo, and
keep the cluster matching it." Not a Kubernetes-native concept — it only
exists once Argo CD is installed.
**app-of-apps** — The pattern this repo uses: one Application (itself
called the app-of-apps) watches a folder of *other* Application manifests,
so adding a new app is "add a file," not "run a new command." See
[`docs/04-app-of-apps.md`](04-app-of-apps.md).
**ARP (Address Resolution Protocol)** — How devices on the same local
network find each other's hardware addresses. MetalLB's L2 mode works by
answering ARP requests for whichever IP address it's handed out, which is
what makes that address reachable from the rest of your LAN.
**bootstrap** — The one-time setup that has to happen *before* the normal
system can take over and manage itself. Used here for `talosctl bootstrap`
(initializes etcd, once, ever) and more generally for the handful of manual
steps — installing Argo CD, applying the app-of-apps — that get the
GitOps loop started before Git takes over.
**cluster** — A group of one or more machines (nodes) working together as
one Kubernetes system. This repo's cluster starts as a single node.
**CNI (Container Network Interface)** — The plugin responsible for giving
Pods network connectivity and IP addresses. Talos ships Flannel, a CNI, by
default — you don't install one yourself in this setup.
**ConfigMap** — A Kubernetes object that holds configuration data (text,
small files) as its own resource, separate from the Pod that uses it. Lets
you change configuration without rebuilding a container image. Used for the
demo app's HTML page.
**control plane** — The set of components that run the cluster itself
(API server, scheduler, etcd) as opposed to your applications. Normally
kept on separate machines from your workloads; this repo's single-node
setup deliberately blurs that line for simplicity — see
[`talos/patch-controlplane.yaml`](../talos/patch-controlplane.yaml).
**CRD (Custom Resource Definition)** — A way to teach Kubernetes about a
new *kind* of object beyond its built-ins (Pod, Service, etc.). Argo CD's
install adds a CRD for `Application`, which is what lets you write
`kind: Application` in YAML at all.
**declarative** — Describing the state you want ("one copy of nginx
running"), and letting software figure out and continuously enforce the
steps to get and stay there — as opposed to **imperative**, where you issue
the individual commands yourself ("start this container now"). Kubernetes,
Talos, and Argo CD are all declarative; the one-time install steps in this
repo (installing Argo CD itself, applying the app-of-apps) are the
deliberate imperative exceptions that get the declarative system started.
**Deployment** — A Kubernetes object describing how many copies of a
container should run and how to run them. You manage Deployments; they, in
turn, manage the actual Pods.
**etcd** — The database Kubernetes' control plane uses to store all
cluster state. `talosctl bootstrap` initializes it, once, on cluster
creation.
**GitOps** — Using a Git repository as the source of truth for a system's
desired state, with automation (here, Argo CD) continuously reconciling
the real system to match it. The core idea this whole repo demonstrates.
**Ingress** — A Kubernetes object that routes external HTTP(S) traffic to
services inside the cluster by hostname/path, via an Ingress controller.
Not used in this repo's v1 — see
[`docs/08-growing-up.md`](08-growing-up.md).
**IPAddressPool** — A MetalLB custom resource (see CRD) declaring which IP
addresses it's allowed to hand out to `LoadBalancer` Services. Configured
in [`apps/metallb/templates/ipaddresspool.yaml`](../apps/metallb/templates/ipaddresspool.yaml)
— see [`docs/05-metallb.md`](05-metallb.md).
**kubeconfig** — A file holding the credentials and connection info
`kubectl` (and other Kubernetes tools) use to talk to a specific cluster.
Retrieved with `talosctl kubeconfig` in this setup.
**L2Advertisement** — A MetalLB custom resource that tells it to advertise
addresses from a given `IPAddressPool` using ARP (see above) — "Layer 2
mode," MetalLB's simplest mode and the one this repo uses. Configured in
[`apps/metallb/templates/l2advertisement.yaml`](../apps/metallb/templates/l2advertisement.yaml).
**LoadBalancer (Service type)** — A Service that requests a real, dedicated
external IP address, reachable directly from outside the cluster. On a
cloud provider this provisions an actual cloud load balancer; on bare
metal (this repo) MetalLB fills that role instead — see
[`docs/05-metallb.md`](05-metallb.md).
**machine config** — Talos's equivalent of a Kubernetes manifest, but for
the operating system itself: a declarative file describing how a node
should be configured, generated with `talosctl gen config` and sent to the
node with `talosctl apply-config`.
**manifest** — A YAML (or JSON) file describing a Kubernetes object's
desired state. Every `.yaml` file in `apps/` is a manifest.
**MetalLB** — Software installed in this repo (`apps/metallb.yaml`) that
gives bare-metal Kubernetes clusters the ability to hand out real IP
addresses to `LoadBalancer` Services — a job a cloud provider would
normally do. See [`docs/05-metallb.md`](05-metallb.md).
**namespace** — A way to group related objects inside a cluster, like a
folder. Lets you keep different apps' resources organized and manageable
independently.
**node** — A single machine (physical or virtual) that's part of a
Kubernetes cluster.
**Pod** — The smallest deployable unit in Kubernetes: one or more
containers that run together, sharing network and storage. You'll rarely
create Pods directly — a Deployment creates and manages them for you.
**reconciliation** — The repeating process of comparing desired state
(Git, for Argo CD) against actual state (the live cluster) and correcting
any difference. See the diagram in
[`docs/00-what-is-all-this.md`](00-what-is-all-this.md#the-reconciliation-loop).
**Secret** — A Kubernetes object for holding sensitive data. Its values are
only base64-*encoded*, not encrypted — never commit a raw Secret manifest
containing real credentials to Git. See
[`docs/08-growing-up.md`](08-growing-up.md) for how to do this safely.
**selfHeal** — An Argo CD sync policy setting: if the live cluster drifts
from what's in Git (e.g. someone runs `kubectl edit`), Argo CD reverts it
back to match Git on its next pass, rather than only fixing drift when you
manually trigger a sync.
**Service** — A Kubernetes object giving a stable network address for a
set of Pods, even as individual Pods are created and destroyed. This repo
uses a `LoadBalancer` Service for the demo app, backed by MetalLB — see
[`apps/demo/service.yaml`](../apps/demo/service.yaml) and
[`docs/05-metallb.md`](05-metallb.md).
**sync** — Argo CD's term for actually applying a change (a reconciliation
pass that found and fixed a difference). "Synced" means the cluster
currently matches Git.
**sync-wave** — An Argo CD annotation
(`argocd.argoproj.io/sync-wave`) that stages the order resources are
applied within one Application's sync: lower numbers first, and Argo CD
waits for a wave to be healthy before starting the next. Used in
`apps/metallb/templates/` so MetalLB's own resources (including its CRDs)
apply before the `IPAddressPool`/`L2Advertisement` that depend on them —
see [`docs/05-metallb.md`](05-metallb.md).
**Talos Linux** — The operating system this repo's node(s) run: built only
to run Kubernetes, configured entirely declaratively over the network, with
no SSH or shell. See [`docs/02-talos-bootstrap.md`](02-talos-bootstrap.md).
**talosconfig** — The credentials file `talosctl` uses to talk to a Talos
node's API — the Talos-level equivalent of a kubeconfig.
+42
View File
@@ -0,0 +1,42 @@
# Network map
Every real IP address this cluster hands out, and exactly what's reachable
there. This file isn't a template with the rest of the repo — it's a log
of *your* actual setup, meant to be updated every time an address is
assigned or changed, so you (or Claude) never have to reverse-engineer
"wait, what's running on .241 again?" from `kubectl` output.
> **Keep this current.** If you're working with Claude and an IP changes —
> a new app gets a `LoadBalancer` address, the MetalLB pool is resized,
> Argo CD's address changes — ask it to update this file, or just point it
> here after any networking change. See `CLAUDE.md` for the standing
> instruction that tells Claude to do this on its own when it makes such a
> change itself.
## MetalLB pool
| | |
|---|---|
| Range | `EDIT ME — e.g. 192.168.1.240-192.168.1.250` |
| Configured in | [`apps/metallb/templates/ipaddresspool.yaml`](../apps/metallb/templates/ipaddresspool.yaml) |
## Allocated addresses
| Address | What | Namespace | Port(s) | URL | Source |
|---|---|---|---|---|---|
| `<NODE_IP>` | Talos node — Kubernetes API server | — | 6443 | `https://<NODE_IP>:6443` | Not from the MetalLB pool — the node's own address, set in [`talos/README.md`](../talos/README.md) |
| `EDIT ME` | Argo CD UI/API (`argocd-server`) | `argocd` | 443 | `https://EDIT ME` | Patched to `LoadBalancer` by hand — see [`docs/05-metallb.md`](05-metallb.md) |
| `EDIT ME` | Demo app | `demo` | 80 | `http://EDIT ME` | [`apps/demo/service.yaml`](../apps/demo/service.yaml) |
Add a row here every time you add a new app with a `LoadBalancer` Service
(see [`docs/07-adding-new-apps.md`](07-adding-new-apps.md)). Leave the URL
column's port number off only when it's the protocol default (80 for
`http://`, 443 for `https://`) — include it otherwise.
## Free in the pool
Addresses from the range above that aren't spoken for yet. Update this
alongside the table above so it's obvious at a glance what's safe to hand
to the next app.
- `EDIT ME`
+123
View File
@@ -0,0 +1,123 @@
# Talos bootstrap — command reference
This is the detailed, copy-pasteable companion to
[`docs/02-talos-bootstrap.md`](../docs/02-talos-bootstrap.md). Read that file
first for the explanations — this one is here so you have every exact
command in one place once you understand what they do.
Throughout, replace `<NODE_IP>` with your machine's real IP address. If it's
a VM, give it a DHCP reservation (a fixed IP tied to its MAC address) in your
router or hypervisor before you start, so the address never changes under
the cluster. Talos bakes the IP into certificates during setup, and changing
it later means starting over.
All commands assume you're running them from the root of this repo.
## 1. Generate machine configuration
```sh
talosctl gen config gitops-starter https://<NODE_IP>:6443 \
--output-dir _out \
--config-patch-control-plane @talos/patch-controlplane.yaml
```
This writes three files into `_out/` (already covered by this repo's
`.gitignore` — they contain private keys and must never be committed):
- `controlplane.yaml` — the full machine configuration for a control-plane
node, with our single-node patch baked in.
- `worker.yaml` — a worker machine configuration. Unused for now (single
node), kept for when you follow docs/08-growing-up.md.
- `talosconfig` — *your* client credentials for talking to the Talos API on
this cluster (equivalent in spirit to a kubeconfig, but for Talos itself
rather than Kubernetes).
`gitops-starter` is the cluster name — an arbitrary label baked into the
generated certificates and config, used to tell clusters apart if you ever
manage more than one. `https://<NODE_IP>:6443` is the cluster's Kubernetes
API endpoint: port 6443 is the standard Kubernetes API server port, and this
same address gets written into the certificates and your future kubeconfig.
## 2. Point talosctl at this cluster
```sh
export TALOSCONFIG=_out/talosconfig
talosctl config endpoint <NODE_IP>
talosctl config node <NODE_IP>
```
`endpoint` is which machine talosctl *talks to*. `node` is which machine a
command *applies to*. On a single-node cluster they're the same address, but
Talos always keeps the two concepts separate, because on a multi-node
cluster you often want to ask one node about another.
## 3. Apply the configuration
Boot the target machine from the Talos ISO (see
[docs/01-prerequisites.md](../docs/01-prerequisites.md) for where to get it).
It boots into "maintenance mode" — alive enough to accept a configuration,
with nothing installed yet. Confirm it's reachable, then apply:
```sh
talosctl get disks --insecure --nodes <NODE_IP>
```
Note the disk name you want Talos installed to (e.g. `/dev/sda`). Then:
```sh
talosctl apply-config --insecure \
--nodes <NODE_IP> \
--file _out/controlplane.yaml
```
`--insecure` is required for this one command only: the node has no
certificates yet, so talosctl can't authenticate to it normally. This is
the one moment during setup where you're trusting the network — do this on
a network you control. The node will reboot, install Talos to disk, and
come back up running the configuration you gave it — now *with*
certificates, so every command after this uses the secure path.
## 4. Bootstrap etcd
Talos is installed and Kubernetes components are starting, but the cluster
has no data store yet. This one-time command initializes it:
```sh
talosctl bootstrap --nodes <NODE_IP>
```
Run this exactly once per cluster, against exactly one control-plane node,
even if you later add more. Running it again on an existing cluster can
corrupt it.
## 5. Get your kubeconfig
```sh
talosctl kubeconfig --nodes <NODE_IP> .
```
Downloads a kubeconfig into the current directory (also gitignored —
it grants full cluster access). Merge it with `~/.kube/config` or point
`KUBECONFIG` at it directly:
```sh
export KUBECONFIG=$PWD/kubeconfig
```
## 6. Verify
```sh
kubectl get nodes
```
Expect to see one node, `Ready`, within a couple of minutes (it takes a
moment for the default CNI — Talos ships Flannel out of the box — to come
up and clear the node's networking checks).
```
NAME STATUS ROLES AGE VERSION
talos-... Ready control-plane 90s v1.3x.x
```
If it's stuck on `NotReady`, give it another minute before troubleshooting —
first boot does a lot of one-time work.
+15
View File
@@ -0,0 +1,15 @@
# A patch applied on top of the controlplane.yaml that `talosctl gen config`
# produces (see talos/README.md and docs/02-talos-bootstrap.md for the full
# command). Talos's generated defaults assume a multi-node cluster where
# control-plane nodes only run the Kubernetes control plane itself (API
# server, scheduler, etcd, etc.) and never your actual workloads. For a
# single-node learning cluster, that would mean nothing can ever be
# scheduled — there's no other node to run it on.
#
# This one field undoes that restriction, letting the single node be both
# the control plane AND a worker.
#
# >> Ask Claude: "why does Kubernetes normally keep workloads off control
# plane nodes?" if you want the reasoning before you turn it off.
cluster:
allowSchedulingOnControlPlanes: true