bit-habit · platform infrastructure

How the servers run — from the URL you type to the page you see

One small server hosts every bit-habit.com website. This page explains how, in five stages that get harder in order — each one starts by telling you what you will be able to say when you finish it, and ends with a few questions to check yourself. Stage 1 assumes you know nothing at all. Stop at whatever stage answers your question; nothing later is a prerequisite for using the sites.

● See what's up right now

1

NO PRIOR KNOWLEDGE NEEDED

Stage 1 · Start here

What this platform is, and the complete path a request takes from your browser to the page you see.

about 2 minutesstart from zero

LIVEIs everything up right now?

After this you can say which sites are up right now, and which one is not.

Each site is checked from the outside every 60 seconds. Full status page → status.bit-habit.com.

Loading live status…

10 SECONDSThe whole thing in one sentence

After this you can say what this entire platform is, in one sentence.

One small server runs every bit-habit.com website. You type an address, it finds the right app, and sends the page back.

bit-habit.comstartpage.bit-habit.comblog.bit-habit.com wiki.bit-habit.complane.bit-habit.com… ~20 sites
flowchart LR U["You (browser)"] --> S["One server\n(runs k3s)"] --> A["The right app\nreplies"]

Want to know how it picks "the right app"? Keep reading.

1 MINUTEWhat happens when you open startpage.bit-habit.com

After this you can say every step between typing an address and seeing a page.

Follow one request from your browser to the page. Six steps.

  1. Find the server. Your browser asks DNS "where is this address?" Every *.bit-habit.com name points to one server IP: 158.180.71.122.
  2. Knock on the front door. The request arrives at Traefik — the program that receives every request, listening on ports 80 and 443.
  3. Force HTTPS. If you came on plain http (port 80), Traefik sends you to secure https (port 443). It's a permanent redirect (status 308).
  4. Lock the connection. Traefik proves the site is genuine with its certificate — that's the padlock in your browser bar.
  5. Route to the app. Traefik reads the address startpage.bit-habit.com and forwards the request to the matching app.
  6. Answer. The startpage app builds the page and sends it back. Done.
flowchart TB U["You type startpage.bit-habit.com"] --> DNS["DNS: name to one IP\n158.180.71.122"] DNS --> T["Traefik front door\nports 80 and 443"] T --> TLS["http to https (308)\nthen HTTPS with the cert"] TLS --> R["Read the address\nroute to the app"] R --> APP["startpage app\nreplies with the page"]

Checkpoint · end of Stage 1

Answer these before moving on. Open each one to check yourself — if any answer is a surprise, that part is worth a re-read.

Every *.bit-habit.com address points at how many servers?

One. A single Oracle Cloud box runs all ~20 sites, and wildcard DNS sends every name to its one IP.

Which program receives your request first?

Traefik. It holds ports 80 and 443 on the host and is the only thing listening on them.

You typed http:// instead of https://. What happens?

Traefik answers with a permanent redirect (status 308) to the https address, then serves the page over the encrypted connection.

2

THE VOCABULARY

Stage 2 · The parts, named

The name of every moving part — Traefik, Ingress, Service, Pod, cert-manager, k3s — and what each one is for.

about 5 minutesneeds Stage 1

5 MINUTESThe parts, named — and what's running

After this you can say the name of each moving part, and what each one does.

The server runs k3s, a small version of Kubernetes. Kubernetes is just a manager that starts apps, restarts them if they crash, and routes traffic to them. Here are its parts, in plain terms.

the parts
PartThink of it asWhat it does
Traefikthe front doorreceives every request on ports 80 / 443, adds HTTPS, and sends it to the right app
Ingressthe routing listthe table that says "this address → that app"
Servicea stable inside addressa fixed name for an app, even after it restarts
Podthe running appthe container doing the actual work
cert-managerthe certificate robotgets and renews the HTTPS certificate automatically
k3sthe managera small Kubernetes that starts, restarts, and watches every app

And here is how those parts connect for a single app — a Deployment keeps Pods running, a Service gives them one address, an Ingress maps the hostname to that Service, and Traefik (with a cert from cert-manager) is the door:

flowchart TB U["Browser"] --> TR["Traefik\nentry, ports 80/443"] CM["cert-manager"] -. "TLS cert" .-> TR TR --> ING["Ingress\nhostname to Service"] ING --> SVC["Service\none stable address"] DEP["Deployment\ndesired replicas"] --> P1["Pod"] DEP --> P2["Pod"] SVC --> P1 SVC --> P2

What's running here

About 20 apps share this one server. A few you can visit:

AddressWhat it is
bit-habit.comthe landing page (static site)
startpage.bit-habit.coma personal start dashboard
blog.bit-habit.comthe blog (Ghost)
wiki.bit-habit.comthe knowledge base (Wiki.js)
habit.bit-habit.comthe habit tracker (web app + API)
plane.bit-habit.comproject management (self-hosted)
fider.bit-habit.comfeature-request voting
quali-fit.bit-habit.coman explainable staffing recommender (sign-in required)
physical-spark.bit-habit.coma Physical AI school — static site + an auth API
llm-app-lab.bit-habit.coman LLM course site
headlamp.bit-habit.comthe live cluster dashboard (sign-in required)
🔒 One certificate for all. Instead of one HTTPS certificate per site, there is a single wildcard certificate for *.bit-habit.com. Every subdomain shares it, so a new site is HTTPS with zero extra setup.

Checkpoint · end of Stage 2

Answer these before moving on. Open each one to check yourself — if any answer is a surprise, that part is worth a re-read.

A Pod crashes, restarts, and comes back with a different IP address. What did not change?

The Service. That is the entire point of a Service: it is a stable name in front of Pods that come and go.

Which part decides that wiki.bit-habit.com should reach wikijs-svc?

The Ingress — it is the routing rule. Traefik is the program that reads it and acts on it. Rule and reader are two different things.

Who renews the HTTPS certificate before it expires?

cert-manager, automatically. One wildcard certificate for *.bit-habit.com covers every subdomain, so a new site needs no certificate work at all.

🛑 You can stop here. If you came to understand how a bit-habit.com page reaches your browser, Stages 1 and 2 are the whole answer — you now know every part in the path and what each one does. Everything below is about running and changing the platform, which is a different job. Come back to it when you need it.
3

OPERATIONS

Stage 3 · Running it

How your code actually gets onto the server, and what it looks like — and how you diagnose it — when something breaks.

about 15 minutesneeds Stage 2

DEPLOYSThe other direction — how the code gets here

After this you can say how code travels from your laptop to a live site — and why Kubernetes plays no part in noticing your push.

Everything above follows a request inward: browser → Traefik → app. This section follows code outward: laptop → GitHub → server → live. It is the half of the story people most often guess wrong.

⚠️ Kubernetes does not watch GitHub. This surprises almost everyone. Kubernetes only keeps a declared state true — "one nginx Pod must be running" — so it restarts crashed Pods and survives reboots. It has no idea GitHub exists and will never notice that you pushed. Something outside the cluster must always be the one to say "there is new code." Here, that something is GitHub Actions.

Following one git push

  1. You push to main.
  2. GitHub Actions wakes up and opens an SSH connection to the server, using a deploy key kept as a repository secret.
  3. The server ignores what Actions asked for. That key is pinned in authorized_keys with a forced command: no matter what command arrives, only one script runs. Even a leaked key can do nothing else on the box.
  4. The script pulls the repo (git reset --hard origin/main — an exact mirror, so the server never drifts).
  5. It is already live. nginx serves that folder straight off the disk (a hostPath mount), so there is no image to build and no Pod to restart. About ten seconds, end to end.
flowchart TB P["git push to main"] --> GA["GitHub Actions\nthe only thing watching GitHub"] GA -->|"SSH with a deploy key"| FC["Forced command\nthe key can run one script, nothing else"] FC --> PULL["git reset --hard origin/main\ninto ~/workspace/the-repo"] PULL --> HP["nginx already serves that folder\n(hostPath) — live, no rebuild"] PULL --> IMG["only if the app is a real image:\ndocker build, then rollout restart"]
why it is this fast

Most static sites deploy by baking the HTML into a container image, pushing it to a registry, and rolling out new Pods. Here, nginx reads the git checkout on the server's disk directly. So the moment git pull finishes, the new page is being served — the same Pod, untouched, uptime intact. A build step buys nothing when the artifact is the source.

Two layers, deployed differently

It matters which of these you changed — they do not travel the same road.

What changedLives inHow it reaches the cluster
App content
pages, code
each app's own repo automatic — GitHub Actions on every push (see above)
Infrastructure
Ingress, certs, Deployments
this repo, bit-habit-infra manual — kubectl apply by hand. It changes rarely, and a bad Ingress can take every site down at once, so a human stays in the loop.

Three repos ship themselves this way today:

RepoSiteWhat its deploy does
portfolio-bithabitbit-habit.comgit pull — that's the whole deploy
llm-app-labllm-app-lab.bit-habit.comgit pull
physical-spark physical-spark.bit-habit.com pull; rebuild the auth image only if auth/ changed; then kubectl apply its own manifests (ops/deploy.sh)
🔁 Applying the manifests every time makes a deploy self-healing. kubectl apply is idempotent — re-applying an unchanged file costs one unchanged line — so physical-spark applies its manifests on every deploy, not only when they change. Anything someone poked by hand in the cluster is quietly reset to what git says. The file, not the running cluster, is the source of truth. That is the core idea behind GitOps tools like ArgoCD and Flux, reached here with one extra line and nothing new installed.

CASE STUDYThe day habit.bit-habit.com served the wrong website

After this you can say how to diagnose an outage one layer at a time, and why a 200 OK can be a lie.

A real outage on this cluster, written up with START — Situation, Task, Action, Result, Takeaway. The site answered 200 OK the entire time, which is exactly why nobody noticed for months. If you are new to infrastructure, this is the most useful section on the page: everything above describes how things work, and this describes what it looks like when they don't.

New to this? You only need four words, all of them explained in 5 minutes: a Pod is a running app, a Service is its stable inside address, an Ingress is one line in the routing table saying "this web address → that Service", and Traefik is the front door that reads that table. The whole incident is one Ingress line pointing at the wrong Service.

S Situation

what was happening

Opening habit.bit-habit.com showed a personal portfolio page instead of the BitHabit habit-tracking app. Not an error page — the wrong site, served perfectly, with a valid certificate and a 200 OK status.

That detail matters more than it sounds. 200 OK is the code a server sends when everything went fine. Uptime monitors, this page's own live status grid, and the status page at status.bit-habit.com all ask "did it answer 200?" — and the answer was yes, continuously, while the site was completely wrong. The outage was invisible to every automated check.

T Task

what had to be done

Find out why, and restore the Flutter app — without breaking the other ~19 sites that share this one server. That constraint shaped everything: the routing table for every bit-habit.com site lived in a single file, so any careless edit could take all of them down at once.

A Action

how it was actually tracked down

The method here is worth more than the answer: work inward one layer at a time, and after each step ask "is the problem above this layer or below it?"

StepWhat was checkedWhat it ruled out
1 Fetch the page from outside. Status was 200, but the page <title> was the portfolio's, and main.dart.js — the file that is the Flutter app — returned 404. Not a crash, not TLS, not DNS. Something is answering; it just isn't the app.
2 Fetch the neighbouring hostnames. bit-habit.com, www.bit-habit.com and habit.bit-habit.com all returned the same portfolio HTML. Not specific to the habit app. Three names are landing in one place — this is a routing problem.
3 Read the cluster's routing table (kubectl get ingress). The rule said habit.bit-habit.com → static-web-svc. Found the wrong turn. static-web-svc is the portfolio, not the app.
4 Call the Flutter app's Service directly from inside the cluster, bypassing the front door entirely. It returned <title>BitHabit and main.dart.js → 200. The app was never broken. It had been running fine for 103 days with nothing routed to it.
5 Read the portfolio Pod's own nginx config. Its server_name listed only bit-habit.com and www.bit-habit.com — not habit.bit-habit.com. Explained the 200. See below: this is the second failure.

Two independent failures had stacked, and it took both to produce this symptom:

flowchart TB B["Browser asks for\nhabit.bit-habit.com"] --> T["Traefik reads the routing table"] T --> F1["MISTAKE 1\nrule points at static-web-svc\n(the portfolio), not the Flutter app"] F1 --> N["The portfolio Pod's nginx\nreceives the request"] N --> F2["MISTAKE 2\nno server_name matches this host,\nso nginx falls back to its default block"] F2 --> R["200 OK + the wrong site\n(no error anywhere)"] APP["The Flutter app\nRunning, healthy, unreachable"]
the concept that makes this click — nginx default_server

One nginx can host many websites. It decides which one you get by reading the Host: header your browser sends and matching it against each site's server_name. If nothing matches, nginx does not return an error. It quietly hands the request to the first site it has — the default server.

That behaviour is intentional: a server on the public internet is reached by random hostnames and IP scans all day, and it needs some answer. But it means a hostname you forgot to configure does not fail loudly — it silently gets served somebody else's website, with a 200. Whenever you see "the wrong site, but a successful response," check server_name first.

R Result

what was changed

Traffic was restored in one line — but the fix that mattered was the one made afterwards. The bad rule was not just live in the cluster; it was written that way in base/ingress.yaml in this repo. Fixing only the running cluster would have been undone the next time anyone applied that file.

  • Routed the host to the right Service — the Flutter app instead of the portfolio.
  • Brought the app into git. bithabit-web — its Deployment, Service and nginx ConfigMap — existed only inside the running cluster and in no repository at all. It is now declared in apps/bithabit-web/.
  • Split its routing out of the shared file. The host's two rules now live beside the apps they serve — apps/bithabit-web/ingress.yaml for the site, apps/bithabit-api/ingress.yaml for /api/ — and the shared table dropped from 15 hostnames to 14.
  • Verified from outside, including the other sites, to confirm nothing else moved.
Root cause, stated honestly: this was not really a routing bug. It was a single source of truth bug. The app existed in the cluster but in no repo, so the one line pointing at it lived in a file that had never heard of it. A rule with no owner drifts, and nobody notices.

T Takeaway

what to carry forward

1. 200 OK is not proof that anything is right

A status code says the server answered. It says nothing about what it answered. Health checks that only look at the status code will miss an entire class of outage — this one hid for months.

Do instead: assert on content. Gatus, already running here, can require the response body to contain an expected string. One extra line per service turns "it responded" into "it responded with itself."

2. Fix the file, not just the cluster

Changing a live cluster directly makes the symptom go away and leaves the cause in place. The next apply of the original file silently reintroduces the bug. The gap between "what is running" and "what is written down" is called drift.

Do instead: treat the repo as the truth and the cluster as its output. A direct patch is fine to stop the bleeding — but it is not done until the file says the same thing.

3. A resource that lives in no repo will eventually be orphaned

The Flutter app ran happily for 103 days with zero traffic routed to it. Nothing was alerting, because from Kubernetes' point of view nothing was wrong: a Pod that nobody visits is not an error.

Do instead: when you create something in a cluster by hand, write it into the repo in the same sitting. "I'll commit it later" is how an app becomes invisible.

4. One shared file for 15 sites means no one owns any line in it

The bad rule sat in a file about every other site. Nobody reviewing the habit app would open it, and anybody editing it risked the other 14. That is a large blast radius — the amount that can break from one mistake.

Do instead: keep each app's routing next to the app. Two Ingresses can share one hostname (that is how / and /api/ split here), so splitting costs nothing and shrinks the blast radius to one site.

5. Diagnose in layers, and prove each one

The decisive move was calling the app's Service from inside the cluster. That single test split the problem cleanly in half: the app is fine, so the fault is in front of it. Without it, the obvious guess — "the Flutter build is broken" — would have cost hours.

Do instead: at each layer, find the cheapest test that tells you which side the fault is on. Bisect the path, don't guess at it.

try it yourself — the commands used, in order
# 1. from outside: is it answering, and with what?
curl -sS https://habit.bit-habit.com | grep -o '<title>[^<]*'
curl -sS -o /dev/null -w '%{http_code}\n' https://habit.bit-habit.com/main.dart.js

# 2. what does the routing table say for this host?
sudo k3s kubectl get ingress -A
sudo k3s kubectl get ingress main-ingress -o yaml

# 3. bypass the front door — ask the Service directly, from inside
sudo k3s kubectl run t --rm -i --restart=Never --image=curlimages/curl -- \
  curl -sS http://bithabit-web-svc/ | grep -o '<title>[^<]*'

# 4. read the nginx config of whichever Pod answered
sudo k3s kubectl exec deploy/static-web -- cat /etc/nginx/conf.d/default.conf
Still open, and deliberately not fixed in the same change:
  • The Flutter build is mounted from a host directory rather than baked into a container image, so a running Pod cannot tell you which commit it was built from, and there is no rollback. Same pattern as static-web — see what to grow.
  • A second, stale copy of the shared routing table still exists in another repo (daily_seongsu/infra/legacy-migration/04-ingress.yaml). It describes 13 hostnames instead of the current 14 and predates several services. Two files claiming to define the same resource is the same class of bug this incident was. It should be deleted or clearly marked dead.

Naming what you chose not to fix is part of a retrospective. An unwritten known issue is indistinguishable from an unknown one.

Checkpoint · end of Stage 3

Answer these before moving on. Open each one to check yourself — if any answer is a surprise, that part is worth a re-read.

You push to main. Does Kubernetes notice?

No — and this trips up almost everyone. Kubernetes never watches GitHub. GitHub Actions SSHes into the server and pulls, and only then is anything new.

A site returns 200 OK but shows the wrong content. Is it broken?

Yes. 200 only means the server answered — not that it answered correctly. This is exactly the outage in the case study, and it hid for months because every monitor was satisfied.

You fix a bad route with kubectl patch and the site works again. Are you done?

No. The file that produced the bad route is still wrong, so the next kubectl apply silently undoes your fix. Fix the file, not just the cluster.

4

CONFIGURATION

Stage 4 · Where the truth lives

Which file defines what, which of them are in git, and which single file can take every site down at once.

about 15 minutesneeds Stage 3

TODAYWhere every setting actually lives

After this you can say whether this platform is really “all in git”, and exactly what is not.

A fair question to ask of any platform: is the whole thing in one git repo? Almost. Every service that matters is in git — either this repo, or its own. What is left over is small, and worth naming precisely rather than worrying about.

flowchart TB subgraph ONE["bit-habit-infra — the infra repo"] EDGE["Edge + TLS + routing\nTraefik · cert-manager · ingress.yaml"] APPS["17 app manifests\nghost · wikijs · gatus · sentinel · llm-app-lab · …"] end subgraph SELF["The app's own repo — a normal pattern"] PS["physical-spark\nk8s/ + deploy.sh"] QF["quali-fit\nk8s/ in KIBA-Automation/quali-fit"] end subgraph OFF["Off-the-shelf apps — a few YAML files on the server"] PLANE["plane · makeplane/plane"] FIDER["fider · getfider/fider"] end K["k3s cluster\n(the running truth)"] ONE ==>|"kubectl apply, by hand"| K SELF -->|"its own deploy script"| K OFF -.->|"applied by hand, rarely"| K
Everything with a blast radius is in git. The parts that could take every site down at once — the edge (Traefik), TLS (the wildcard cert), and the routing table (base/ingress.yaml) — plus 17 app manifests live in bit-habit-infra. Two apps keep their manifests in their own repo alongside their code (physical-spark, quali-fit), which is a perfectly standard layout, not a gap. Nothing that matters exists only on this disk.
⚠️ The leftovers, named honestly. plane and fider are off-the-shelf apps — stock images (makeplane/plane, getfider/fider) driven by a couple of hand-written YAML files that sit in a home directory and are in no repo. They are also barely used. Losing those files would mean rewriting a Deployment and a Service from a public project's docs — an hour, not a disaster. Worth tidying eventually; not worth calling a risk.

The cluster, right now

1node · OCI Ampere A1 (ARM)
9namespaces
~40Deployments
14Ingress routes
1wildcard TLS cert
$0per month
the industry standard — and where this sits

The standard goal is one line: the running cluster equals what git says, with nothing that exists only on one machine. GitOps tools — ArgoCD or Flux — enforce it by continuously watching a repo and re-applying it, so manual cluster edits are reverted automatically.

For one node run by one person, ArgoCD is deliberately not installed (kept only as reference): it adds more moving parts than it removes, and re-applying manifests on every deploy already buys the self-healing that makes GitOps worth having. Measured against the standard, this platform is closer than it looks — the remaining gap is secrets, not services. See what to grow.

PATHSEvery config, and exactly where it lives

After this you can say which file to open for any setting on this server.

"Where is that configured?" is the question you actually ask at 2am. There are three places a setting can live here, and only one of them is this git repo. Here is all three, with real paths.

flowchart TB R["1 · This git repo\nbit-habit-infra"] A["2 · An app's own git repo\nphysical-spark · quali-fit"] H["3 · The host, outside any repo\nk3s's own directories"] K["k3s cluster"] R -->|"kubectl apply, by hand"| K A -->|"its own deploy script"| K H -->|"k3s auto-applies this folder"| K

1 · In this git repo

On GitHub as bookseal/bit-habit-infra, checked out on the server at /home/ubuntu/workspace/bit-habit-infra.

PathWhat it defines
base/ingress.yamlthe routing table — every subdomain → its Service. The one file that can break every site at once.
base/cert-manager/cluster-issuer.yamlhow TLS certificates get issued (Let's Encrypt, DNS-01 via Route53)
base/cert-manager/certificate.yamlthe wildcard *.bit-habit.com certificate request
base/cert-manager/aws-secret.example.yamla redacted template. The real one (Route53 keys) is gitignored and applied by hand
base/middlewares/Traefik middlewares, e.g. strip the /api prefix before forwarding
apps/<name>/deployment.yamlone folder per service — 17 of them (ghost, wikijs, gatus, sentinel, llm-app-lab, …)
apps/argocd/application.yamlGitOps config, written but never installed — see the next section
k3s-bootstrap/traefik-config.yamlthe edge — Traefik on host ports 80/443, http→https redirect
docs/index.htmlthis page. nginx serves this folder straight off the disk

2 · In the app's own git repo

Two services keep their Kubernetes manifests next to their code, and deploy themselves. This is a normal, defensible layout — the service stays self-contained, at the cost of this repo no longer being the complete picture.

ServiceRepo · path
physical-spark bookseal/physical-sparkk8s/, auth/k8s/, applied by its own ops/deploy.sh. It even brings its own Ingress.
quali-fit KIBA-Automation/quali-fitk8s/ (namespace, PVC, deployment, service, middleware, ingress). The image is tagged with the git commit it was built from, so a running Pod always names its own source.

3 · On the host — k3s's own directories

These are not in the repo, and mostly cannot be. They belong to k3s itself.

PathWhat it is
/var/lib/rancher/k3s/server/manifests/ A magic folder: k3s applies every YAML you drop in here, automatically. It holds Traefik, CoreDNS, local-storage — and traefik-config.yaml, a copy of the repo's file. To change the edge you sudo cp the file here and k3s redeploys Traefik on its own. This is a second source of truth, and ArgoCD can never manage it.
/etc/rancher/k3s/k3s.yamlthe admin kubeconfig — the credentials + address kubectl uses to talk to the cluster. Root-only.
/etc/rancher/k3s/registries.yamlwhere to pull container images from
~/.kube/configthe same kubeconfig, copied for a normal user
~/.ssh/authorized_keysthe deploy keys GitHub Actions uses, each pinned to one script (see the deploys section)

And the leftovers

⚠️ Two off-the-shelf apps are driven by YAML that lives in no repo. /home/ubuntu/plane/k8s/ and /home/ubuntu/fider/fider.yaml deploy stock upstream images (makeplane/plane, getfider/fider) — a Deployment, a Service, a Postgres. Nothing bespoke, nothing precious, and both are barely used. Recreating them from the upstream docs is an hour of work, so this is a tidiness item, not a risk. The real thing still outside git is secrets — see what to grow.

Checkpoint · end of Stage 4

Answer these before moving on. Open each one to check yourself — if any answer is a surprise, that part is worth a re-read.

A setting can live in one of three places. Name them.

1) this infra repo, 2) an individual app’s own repo, 3) on the host itself, in k3s’s own directories — the last of which no GitOps tool can ever manage.

Which single file can take every site down at once?

base/ingress.yaml, the shared routing table. That is why it is applied by hand, and why moving rules out of it into per-app files shrinks the blast radius.

Something is running in the cluster but exists in no repository. What is the risk?

Nobody owns it, nothing reviews it, and it cannot be rebuilt if the server is lost. It can also be quietly orphaned — the case study’s Flutter app ran for 103 days with no traffic routed to it.

5

GOING FURTHER

Stage 5 · What comes next

What GitOps actually means, what adopting ArgoCD here would cost, and what this platform should fix first.

about 15 minutesneeds Stage 4

GITOPSArgoCD — the idea, and what it would actually take

After this you can say what GitOps means, and what installing ArgoCD here would actually cost.

GitOps is one idea in one sentence: put the desired state in git, and let a program inside the cluster continuously make reality match it. That's it. ArgoCD is the most common program for doing that. Here is what changes, and what it would cost here.

The one thing that changes: push becomes pull

Today, everything pushes into the cluster from outside — GitHub Actions SSHes in, or a human runs kubectl apply. The cluster is passive; it never looks at git. GitOps inverts that: a controller runs inside the cluster and pulls from git forever.

flowchart TB subgraph PUSH["Today — push model"] GH["Git (GitHub)"] --> ACT["GitHub Actions,\nor you at a terminal"] ACT ==>|"pushes changes in"| K1["k3s cluster\n(passive — never reads git)"] end subgraph PULL["GitOps — pull model"] AGO["ArgoCD controller\nrunning inside the cluster"] -->|"reads the repo\nevery ~3 minutes"| GH2["Git (GitHub)"] AGO ==>|"makes the cluster match git\nforever"| K2["k3s cluster"] K2 -->|"someone edited by hand?\nthat's drift"| AGO end

The loop, precisely

flowchart LR G["Git repo\nbase/ and apps/\n= desired state"] --> C["Compare"] L["Live cluster\n= actual state"] --> C C -->|"identical"| OK["Synced. Do nothing."] C -->|"different"| S["OutOfSync.\nApply the difference."] S --> L
the vocabulary you need — and nothing more
WordWhat it means
ApplicationArgoCD's core config object. It says: "watch this path in this repo, and keep this namespace matching it." One per group of manifests. Already written here: apps/argocd/application.yaml.
SyncThe act of applying git to the cluster. Can be automatic or a button you press.
Synced / OutOfSyncDoes the cluster currently match git? OutOfSync means someone changed one side.
Healthy / DegradedA separate question: are the Pods actually running? You can be Synced and Degraded — git applied fine, but the app is crashing.
DriftThe cluster no longer matches git. Usually because a human ran kubectl edit at 2am.
selfHealAutomatically undo that 2am edit — re-apply git over it. This is the property people actually want from GitOps.
pruneDelete things from the cluster when they're deleted from git. Powerful and dangerous: a bad file deletion becomes a production deletion. Off (prune: false) in this repo's config.
app-of-appsOne Application whose job is to create the other Applications. Adding a new service becomes "add one file", not "click around the UI".

What installing it actually looks like

Five commands. The wildcard DNS and certificate already cover a new subdomain, so the UI is reachable with no DNS or TLS work.

🧑‍🏫 Want to actually do it? There is a hands-on, step-by-step walkthrough of this exact install — with the cluster's quirks baked in, quizzes, and a progress checklist: ArgoCD 설치 튜토리얼 (한국어).
# 1. a namespace to live in
kubectl create namespace argocd

# 2. install ArgoCD itself (one big official manifest)
kubectl apply -n argocd -f \
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# 3. let Traefik handle TLS, so ArgoCD serves plain http internally
kubectl -n argocd patch deployment argocd-server --type json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--insecure"}]'

# 4. route argocd.bit-habit.com to it (wildcard cert already covers this name)
kubectl apply -f apps/argocd/ingress.yaml

# 5. tell ArgoCD which repo and paths to watch — this file already exists
kubectl apply -f apps/argocd/application.yaml

Step 5 is the whole idea, and it is already written in this repo:

spec:
  source:
    repoURL: https://github.com/bookseal/bit-habit-infra.git
    targetRevision: main        # watch the main branch
    path: base                  # watch this folder
  syncPolicy:
    automated:
      prune: false              # do NOT delete things removed from git (safety)
      selfHeal: true            # DO undo hand-edits made in the cluster
🤔 Why it is still not installed. Re-applying manifests on every deploy already buys selfHeal, which is the valuable half — and it costs nothing to run. ArgoCD adds ~5 Pods (controller, repo-server, api-server, redis, dex) to a free-tier ARM box, plus one more web UI to lock down. For one node run by one person, that trade is honestly close. The real argument for adopting it is not the tool — it's that the tool forces everything into git. You can get that benefit first, and for free, without installing anything.

THE GAPWhat to grow, in order

After this you can say what this platform should fix next, and in what order.

Honest scorecard: what this platform would need before GitOps is even possible, ranked by what actually matters. There is exactly one real blocker, and it is not the services — it is the secrets.

THE ONE BLOCKER  Decide how secrets get into git

Today: real secrets are gitignored and applied by hand — the Route53 keys, quali-fit's basic-auth, oauth2-proxy's client secret. Git holds only redacted *.example.yaml templates.

Why it blocks GitOps: a controller that syncs "everything in git" cannot sync what is deliberately absent from git. You would keep a manual step forever, and every fresh install would silently come up broken.

The options, cheapest first:

  • Sealed Secrets — encrypt a Secret with a key only this cluster holds; the encrypted file is safe to commit. Simplest thing that works for one cluster.
  • SOPS + age — encrypt files with a key you keep; works without any cluster-side operator, but ArgoCD needs a plugin to decrypt.
  • External Secrets — keep secrets in AWS/Vault and sync them in. Most robust, most moving parts. Overkill here.

TIDYING  Commit the two leftover YAML files

Today: plane and fider are deployed from YAML that sits in a home directory and is in no repo. Both are stock upstream apps, and barely used.

Why it is low on this list: ArgoCD can only sync what is in a repo, so strictly these would have to move before it could manage them. But there is nothing bespoke in them — losing them costs an hour with the upstream docs, not a service. Drop them in apps/ whenever it is convenient.

NEXT  Give apps their own namespaces

Today: almost everything is jammed into the default namespace. Only plane, quali-fit, fider and headlamp have their own.

Why it matters: a namespace is the unit ArgoCD scopes an Application to, and the unit prune deletes within. With one shared default, turning on prune means one controller with permission to delete everything. Namespaces are what make prune safe to ever enable.

NEXT  Accept that host-level config stays out

/var/lib/rancher/k3s/server/manifests/ is applied by k3s itself, before and below ArgoCD. Traefik's own configuration will always be a sudo cp onto the host. That is fine — but it should be written down, not discovered. Do not chase 100%: aim for "everything that can be in git, is."

LATER  Then, and only then, consider ArgoCD

With secrets encrypted in git and namespaces per app, installing ArgoCD becomes the five commands above and it just works. Do it in that order and it is a calm afternoon. Do it in the reverse order and you spend a week fighting a controller that keeps trying to sync a repo that does not contain the truth.

the scorecard, at a glance
DimensionTodayNeeded for GitOps
Edge, TLS, routing✅ in git✅ already there
Most app manifests✅ in git (17 apps)✅ already there
physical-spark · quali-fit✅ in their own git repos✅ fine — ArgoCD reads many repos
plane · fider⚠️ stock apps, YAML in no repocommit them — an hour, low stakes
Secretsgitignored, applied by handencrypted in git (Sealed Secrets)
Namespaces⚠️ mostly one shared defaultone per app, so prune is safe
Self-healingkubectl apply on every deploy✅ already have the property
Host-level (Traefik, CoreDNS)manual sudo cpstays manual — out of scope, by design
The takeaway. The gap between here and "industry standard" is not a missing tool, and it is not the services — every service that matters is already in a repo. It is one thing: secrets are the only part of this platform that git does not know about. Close that and this is GitOps-ready, whether or not ArgoCD is ever installed.

Checkpoint · end of Stage 5

Answer these before moving on. Open each one to check yourself — if any answer is a surprise, that part is worth a re-read.

Define GitOps in one sentence.

Git holds the desired state, and a program inside the cluster continuously makes reality match it.

Of all ArgoCD’s features, which one do people actually want?

selfHeal — automatically undoing hand-made cluster edits by re-applying git over them. Notably, re-applying manifests on every deploy already buys most of this without installing anything.

What is the one real blocker to adopting GitOps here?

Secrets. A controller that syncs “everything in git” cannot sync what is deliberately kept out of git. It is not the services — those are already there.

·

REFERENCE

Appendix · Look things up

Not a stage to read in order — two references to come back to. How to add a site yourself, and every word on this page defined once.

dip in as neededno order required

HOW-TOGo deeper — and add a site yourself

After this you can say how to add a new site to this cluster yourself.

How a new site gets added

  1. Put the app in the cluster — a small Deployment (the app) and a Service (its inside address).
  2. Add one line to the routing list — an Ingress rule: newsite.bit-habit.com → that Service.
  3. Apply it (kubectl apply). That's all: the wildcard DNS and wildcard certificate already cover the new subdomain, so it's reachable over HTTPS right away.
building blocks, precisely
  • k3s — a lightweight, single-node Kubernetes. It stores its state in SQLite instead of the usual etcd.
  • Traefik — the ingress controller. It binds the host's ports 80 and 443 directly and terminates TLS (no separate load balancer).
  • Ingress — the rules that map each hostname to a Service.
  • Service (ClusterIP) — a stable in-cluster address for a set of Pods.
  • Pod — one running instance of an app (one or more containers).
  • cert-manager — requests and renews the Let's Encrypt certificate using a DNS-01 challenge through AWS Route53.
Edge facts
• Public IP 158.180.71.122 (private 10.0.0.61 inside Oracle Cloud).
• Traefik holds ports 80 and 443 on the host directly.
• Plain http → permanent 308 redirect to https.
• One wildcard certificate *.bit-habit.com (Let's Encrypt, DNS-01 via Route53), stored in the Secret tls-secret and auto-renewed before its 90-day expiry.

See the real configs

It is all plain YAML in the public repo. A whole small app is often just this:

apiVersion: apps/v1
kind: Deployment            # keep N copies of my app running
metadata: { name: myapp }
spec:
  replicas: 1
  selector: { matchLabels: { app: myapp } }
  template:
    metadata: { labels: { app: myapp } }
    spec:
      containers:
        - name: web
          image: nginx:alpine
---
apiVersion: v1
kind: Service               # one stable address for those copies
metadata: { name: myapp-svc }
spec:
  selector: { app: myapp }
  ports: [{ port: 80, targetPort: 80 }]

The key files that run this platform (click to view on GitHub):

FileWhat it defines
k3s-bootstrap/traefik-config.yamlthe edge — Traefik on host ports 80/443, http→https redirect
base/ingress.yamlthe routing table — every subdomain → its Service
base/cert-manager/cluster-issuer.yamlhow TLS is issued — Let's Encrypt + Route53 DNS-01
base/cert-manager/certificate.yamlthe wildcard *.bit-habit.com certificate
apps/llm-app-lab/deployment.yamla minimal app — Deployment + Service
apps/gatus/deployment.yamlthe status page — Deployment + Service + ConfigMap

Go further

● Open the full status page →

WORDSEvery term on this page, in plain language

This page uses jargon that sounds like it assumes you already know it. You don't have to. Here is every word, defined once, with where it actually shows up here.

the server itself
TermIn plain words
bootstrapThe one-time setup that brings a machine from "empty Linux box" to "running cluster". You do it once and mostly never again — which is exactly why it must be written down. That is what k3s-bootstrap/ is.
systemd serviceHow Linux keeps a program running in the background and restarts it after a reboot. k3s runs as one. systemctl status k3s asks "is it alive?"
SSHThe encrypted remote login used to reach the server from elsewhere.
deploy keyAn SSH key given to an automated system (here, GitHub Actions) rather than a person, so it can log in without a password.
~/.ssh/authorized_keysThe list of keys allowed to SSH into this server. If a key is in this file, it can log in.
forced commandThe security trick this platform leans on. Next to a key in authorized_keys, you can pin command="/path/to/one-script.sh". Then no matter what the remote side asks to run, the server runs only that script and nothing else. So a leaked deploy key cannot open a shell, read files, or delete anything — it can only trigger a redeploy.
sudo"Run this as the administrator (root)." Needed to touch system directories like /var/lib/rancher/.
git pull / git reset --hard origin/mainFetch the latest code. The reset --hard form throws away any local change and makes the folder an exact mirror of GitHub — so the server can never quietly drift from the repo.
Kubernetes / k3s
TermIn plain words
Kubernetes (k8s)A program that runs your other programs: it starts them, restarts them when they crash, and routes traffic to them. k3s is a small single-binary version of it, which is what runs here.
manifestJust a YAML file describing something you want to exist. "Manifest" and "YAML file" are used interchangeably.
declarativeYou describe the end state ("I want 1 copy of nginx running"), not the steps to get there. Kubernetes figures out the steps and keeps that statement true forever. This is the single biggest idea in the whole system.
kubectlThe command-line tool you use to talk to the cluster. Everything below is a kubectl subcommand.
kubectl apply -f file.yamlThe main command in this whole platform. It means: "read this file, and make the cluster match it." Not "create" — match. If the thing doesn't exist it's created; if it exists but differs it's updated; if it already matches, nothing happens.
idempotentRunning it twice does the same thing as running it once. kubectl apply is idempotent, which is why a deploy can safely re-apply every manifest every single time. That habit is what quietly gives this platform self-healing without ArgoCD.
PodOne running instance of your app (a container). The smallest unit Kubernetes runs.
DeploymentThe manifest that says "keep N Pods of this app running." If a Pod dies, the Deployment makes a new one. This is what restarts your crashed app at 4am.
ServiceA stable internal address for a set of Pods. Pods come and go with new IPs; the Service name never changes.
IngressThe routing rule: "requests for wiki.bit-habit.com go to the wikijs-svc Service." All of them together are the routing table.
ingress controllerThe program that actually does what the Ingress rules say. Here that's Traefik. The Ingress is the rule; Traefik is the doorman reading it.
namespaceA folder for cluster objects. Purely for grouping and permissions. Most things here sit in the one called default.
Secret / ConfigMapConfig handed to a Pod. A Secret is for passwords and keys (base64-obscured, not encrypted); a ConfigMap is for everything else.
hostPathLetting a Pod read a folder from the server's actual disk. It's why this page deploys in ~10 seconds: nginx serves the git checkout directly, so a git pull is the deploy — no image to build, no Pod to restart.
kubeconfigThe file holding the cluster's address and your credentials, so kubectl knows where to connect and how to prove who you are. Lives at /etc/rancher/k3s/k3s.yaml.
CRD (Custom Resource Definition)How a tool teaches Kubernetes a brand-new kind of object. cert-manager adds Certificate; ArgoCD adds Application. After a CRD is installed, kubectl apply handles the new type like any built-in one.
controller / reconcile loopA program that never stops asking "does reality match what was declared? no? fix it." Every piece of Kubernetes is one of these. ArgoCD is simply one more, whose declared state happens to live in git.
desired state vs actual stateWhat you wrote down, versus what is really running. The gap between them is called drift, and closing it is the entire job.
DNS and HTTPS
TermIn plain words
wildcard DNSOne rule — *.bit-habit.com → 158.180.71.122 — that points every possible subdomain at this server. It's why adding a site needs no DNS change at all.
TLS certificateThe file that proves a site is genuinely who it claims to be. It's what produces the padlock, and what makes https possible.
wildcard certificateOne certificate valid for *.bit-habit.com, so every subdomain shares it instead of each needing its own.
Let's EncryptThe free authority that issues those certificates, automatically, every 90 days.
DNS-01 challengeHow Let's Encrypt checks you really own the domain: it asks you to place a specific secret record in your DNS. It's the only method that can issue a wildcard certificate — which is why this setup needs Route53 API access at all.
Route53AWS's DNS service, which hosts this domain. cert-manager holds an API key for it so it can write that challenge record on its own.
cert-managerThe robot in the cluster that requests the certificate, answers the DNS-01 challenge, and renews it before it expires. Nobody touches certificates by hand.
308 redirect"This moved permanently — go to the https version." Traefik sends it for every plain http request.
GitOps
TermIn plain words
GitOpsThe rule that git is the only source of truth: if it isn't in the repo, it shouldn't be in the cluster. A controller enforces it continuously.
push vs pullPush (today): something outside shoves changes in. Pull (GitOps): something inside the cluster fetches from git on its own, forever.
ArgoCD / FluxThe two common programs that do the pulling. They are alternatives to each other; neither is installed here.
ApplicationArgoCD's config object: "watch this repo + this folder, keep this namespace matching it."
syncActually applying git to the cluster.
selfHealAutomatically re-apply git over any hand-made change. The property everyone actually wants.
pruneDelete from the cluster whatever was deleted from git. Genuinely dangerous — deleting a file becomes deleting production. Off here.
Sealed Secrets / SOPSWays to encrypt a secret so the encrypted file is safe to commit. Needed because GitOps cannot sync what is deliberately kept out of git.
when things break — the vocabulary from the case study
TermIn plain words
server_nameThe line in an nginx config that says which web addresses this site answers to. nginx compares it against the Host: header the browser sent.
default_serverThe trap in the case study. If no server_name matches, nginx does not error — it serves its first/default site instead. So a forgotten hostname quietly returns someone else's website with a successful 200.
status codeThe three-digit result of a request. 200 = the server answered normally, 404 = no such page, 500 = the server broke. Note that 200 only means "answered" — not "answered correctly".
single source of truth (SSOT)The rule that exactly one place defines each thing. When two files define the same resource — or when something exists only in the cluster and in no file — you can no longer tell which one is right, and the last person to run a command wins.
driftThe running system and the files describing it have quietly grown apart. Usually created by fixing something directly in production and not updating the file.
blast radiusHow much breaks when one change goes wrong. A routing file covering 15 sites has a blast radius of 15; one file per app has a blast radius of 1.
root causeThe condition that let the bug exist, not the line that displayed it. Here the visible bug was one wrong Ingress line; the root cause was that the app was in no repo, so that line had no owner.
health checkAn automated request that asks "is this site up?" on a timer. Whether it checks only the status code or also the content decides which outages it can see.
retrospectiveA write-up after an incident, aimed at the system rather than the person. START is one format: Situation, Task, Action, Result, Takeaway.
bisectingDebugging by repeatedly cutting the problem in half. Testing the Service from inside the cluster proved the app was healthy, which eliminated everything behind the front door in one step.