DevOps journey · 7 stages
7 stages, one evolving system
7 stages of infrastructure, and — from stage 2 — one application carried through all of them.
Stage 1 had no application at all. It was 4 virtual machines and nothing to serve, which is the honest starting point: you build the ground floor before you have anything to stand on it with.
Read as a curriculum, the 7 stages introduce 10 infrastructure concepts between them — 3 in the first stage, and the rest as the fleet grows from 4 VMs to a reconciled cluster. Every concept has its own page explaining what it is for and where it appears here.
InfraArc
7 stages, 1 reached.
7 stages of infrastructure, and — from stage 2 — one application carried through all of them.
2025-04-08 → 2025-08-25
Server Sorcery 101
Four VMs, one load balancer, and a lot of lost evenings.
virtual machines4 hosts35 commitsno application
Nginx load balancer, two web servers, one app server. WireGuard for management.
Stage 1 had no application at all. It was 4 virtual machines and nothing to serve, which is the honest starting point: you build the ground floor before you have anything to stand on it with.
client:load
On ordering: these stages are logical, not chronological. The application work began on 2025-04-22, before the automation repository began on 2025-05-09, and the work overlapped. Stage 2 covers reproducible provisioning and automated application deployment; stage 3 focuses on the diagnostic application and adaptive load balancing. Both deploy the application. Their order here groups the capabilities, rather than claiming a chronological hand-off.
- 01 / 7 2025-04-08 → 2025-08-25 35 commits no application
Server Sorcery 101 project page →
Four VMs, one load balancer, and a lot of lost evenings.
topology · 4 VMs · 192.168.56.10–13
Nginx load balancer, two web servers, one app server. WireGuard for management.
inherited
Nothing. This is the baseline.
concepts in play
3 of 10 introduced-
A Vagrantfile defined all four VMs — addresses, resources, provisioning order — so the network came up from one command instead of four manual installs.
- Load Balancing new
A hand-written nginx upstream split traffic across two web servers, which is where the idea of a redundant backend stopped being theoretical.
- Observability new
Node Exporter on every VM, Prometheus on the load balancer, Grafana reading from it — with a pre-imported dashboard, the fleet was legible on day one.
what broke here
- 4 fixed / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
Vagrant could not drive VirtualBox from WSL at all. More than seven hours were lost before it worked
`Vagrant is unable to use the VirtualBox provider from the Windows Subsystem for Linux without access to the Windows environment.`
then: The VMs were built on Windows proper and then driven from WSL over the host-only network. The whole `192.168.56.0/24` topology exists to support that path.
- fixed
Hardening locked Vagrant out of its own machines
`AllowUsers devops` → `vagrant@192.168.56.10: Permission denied (publickey)`. Fixed: `AllowUsers devops vagrant`.
then: The SSH configuration lists `vagrant` alongside `devops` in `AllowUsers`, preserving provisioning access in this manually configured stage.
- fixed
Disabling the NAT interface to force egress through a proxy broke Vagrant entirely. Three hypotheses were tested and disproven before the cause surfaced
`50-cloud-init.yaml` contained `dhcp4: false`, which I had set during the earlier proxy work. The VM had lost its IPv4 address on `enp0s3`.
then: A config the earlier proxy work had introduced, reverted so the interface keeps its DHCP lease. The three wrong hypotheses cost more than the fourth would have.
- fixed
Fail2Ban was not enabled at boot, so the sshd jail silently did nothing
`Failed to access socket path: /var/run/fail2ban/fail2ban.sock. Is fail2ban running?` The jail was configured correctly and enforcing nothing.
then: Started the service, then proved it rather than assuming it: six failed logins produced `Connection refused`, and `fail2ban-client status sshd` reported the ban.
4 of 7 — the project page has the rest
what it taught
- Defence in depth is a series of layers, and every one of them is also a way to lock yourself out
- Hardening must not disable the channel your tooling depends on. Deny outbound by default rather than killing the interface.
- Boot-time investigations need both resource-allocation checks and management-interface checks
-
- 02 / 7 2025-05-09 → 2025-07-03 73 commits
Automation Alchemy project page →
Zero-touch provisioning, and an addressing scheme that had to move.
topology · 6 VMs · 192.168.56.20–25
Six VMs: app, two web, load balancer, backup, and a dedicated CI server running Jenkins.
inherited
- The four-VM topology and hardening posture from Server Sorcery 101
- The Vagrant-from-WSL fixes, carried forward and credited in the Vagrantfile
concepts in play
7 of 10 introduced-
Jenkins built the backend image, tagged it with the build ID, gated it on Hadolint, Gosec and Trivy, and pushed it to Docker Hub — the same artefact the deploy stages later pulled.
- CI/CD Pipelines new
Six pipeline stages carried a commit to a running deployment, including a ROLLBACK_TAG parameter that skips build and test and redeploys a previous image.
-
Ansible Vault encrypted the group variables, and Jenkins Configuration as Code injected the vault password from the environment rather than anyone typing it into a UI.
-
A nightly cron rsync to a dedicated backup host using --delete --checksum --partial, paired with the rollback tag for the deployment side of recovery.
-
Twelve idempotent Ansible roles took over from hand-written provisioning, so site.yml could rebuild the whole fleet from a clean clone.
-
Upstreams were templated from the Ansible inventory, with a hook for including an adaptive balancing file. Stage 3 explains the metric-driven generation of that file separately.
-
Prometheus and Grafana became provisioned roles with dashboards attached, so monitoring was rebuilt by the same command that rebuilt everything else.
what broke here
- 3 fixed / 4
- 1 workaround / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
An SSH host-key collision forced the entire address range to change
`Offending ECDSA key in /home/juho/.ssh/known_hosts:3`. Host key verification failed.
then: The six VMs moved from `.10–.15` to `.20–.25` rather than editing `known_hosts`, so the stale keys were sidestepped instead of trusted. That renumbering is why this stage and the one before use different ranges.
- fixed
An Ansible task used `group:` instead of `groups:`, silently destroying sudo membership
`id devops` → `uid=1002(devops) gid=998(docker)`. The task overwrote the primary group rather than appending to it.
then: Corrected to `groups: sudo` in the common role. The tell was `gid=998(docker)`: a supplementary-group mistake shows up in the primary GID because the wrong key replaced the whole group list.
- workaround
WireGuard routing loop, and the fix that was worse than the bug
`THIS IS NOT FIX!!!! BROKE OTHER THINGS!!!!`
then: Resolved on the host, not in the repo: the VPN client no longer routes `192.168.56.0/24` into the tunnel, because that range is the same one the tunnel endpoint lives on. The journal heading is accurate. An earlier attempt made it worse.
- fixed
Docker publishes ports through FORWARD, bypassing UFW INPUT entirely
Docker's iptables rules for published ports take precedence in the FORWARD chain, bypassing UFW INPUT chain rules.
then: UFW rules written for a published port were moving into the `DOCKER-USER` chain instead, so the firewall rules for container ports live there now. Docker consults that chain before its own.
4 of 9 — the project page has the rest
what it taught
- Idempotency is the whole discipline, and it needs a stated target
The goal was a second run ending in `changed=0`. Reached by centralising shared settings into variables, replacing `get_url force: false` with `stat` plus a `when` guard, and `state: file` instead of `state: touch`. The recap read a sea of green.
- Boot timing is a network problem. Let VMs settle their interfaces and set Ansible connection timeouts
- Control the DOCKER-USER chain directly rather than trying to make UFW and Docker agree at the interface
- Network boundaries need concrete access tests, including traffic arriving over the VPN
The write-up states that VPN clients can still reach the backend directly, and says what would fix it: moving the WireGuard gateway onto its own VM.
- 03 / 7 2025-04-22 → 2025-08-26 88 commits
Infrastructure Insight project page →
The application arrives, and the load balancer learns to read metrics.
topology · 5 VMs · 192.168.56.10–14
Five hosts including a self-added backup server. Returns to the original range, now that stage 2 is a separate environment.
inherited
- The virtualised application stack, containerised rather than hand-installed
- Prometheus and node_exporter from stage 1, this time as an input to something rather than a dashboard
concepts in play
7 of 10 introduced-
update_nginx_weights.py reads node_load1 from Prometheus and rewrites the nginx upstream weights so the quieter server receives more traffic. Load balancing became a function of monitoring rather than of configuration.
-
Multi-stage Go and Node Dockerfiles plus Compose services replaced individually run containers, and the pipeline pushed built images rather than shipping a build context.
-
The application consumed metrics instead of exporting them. This is where monitoring stopped being a dashboard and became an input to a control decision.
what broke here
- 1 workaround / 3
- 2 unresolved / 3
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- unresolved
A firewall rule gated on an HTTP header killed every connection, including legitimate ones
The DROP rule inspected the first packets of the TCP handshake, SYN and SYN-ACK, which carry no HTTP data and therefore no secret header. The connection was dropped before the header could ever be sent.
then: Unresolved by design. Header-gating at packet level cannot work, and the rule was removed rather than replaced with something that looks like a fix.
- unresolved
The same problem survived two more attempts: a more elaborate stateful rule, and the canonical UFW Docker patch
`This proved to be extremely brittle and still resulted in a blocked connection.`
then: Both abandoned. Three attempts is the point at which reverting is the cheaper option. The next entry records that.
- workaround
Resolving it was to revert and document the limitation rather than pretend it was solved
`iptables-persistent` was purged for a clean state. The backend port remains reachable on the private VPN, and the write-up says so.
then: Deliberately unresolved. `iptables-persistent` was purged and the backend is reachable over the VPN only. The limitation is documented rather than papered over.
3 of 6 — the project page has the rest
what it taught
- A container lies about its host, so the host OS release has to be mounted in explicitly
- Deep packet inspection on a stateless match cannot coexist with the TCP handshake. That conflict was the actual obstacle, not the rule syntax
- MASQUERADE on the VPN gateway means the app server sees the load balancer rather than the web servers, which makes per-host allow rules unnecessary
- 04 / 7 2025-07-08 → 2025-09-10 107 commits
Sherlock Logs project page →
Two monitoring paradigms, one machine that could not hold either.
topology · 6 VMs · 192.168.56.20–26
Six live hosts: the stage-2 fleet plus a monitor server and a CI server. Prometheus migrated off the load balancer onto the monitor.
inherited
- The six-VM fleet and Ansible patterns from Automation Alchemy
- The application, now instrumented: custom Prometheus metrics on a dedicated port, and structured JSON logging
concepts in play
8 of 10 introduced- Health Checks new
Ansible blocked each deployment until Elasticsearch reported a cluster health of yellow or green, retrying for 50 seconds. This is the first stage where anything in the arc waits on a dependency saying it is usable.
-
The fullest build in the arc: 148 lines of Prometheus rules across four groups, Alertmanager routing, a complete Filebeat to Logstash to Elasticsearch to Kibana chain feeding five dashboards, and predictive disk alerting via predict_linear.
-
Backup automation extended to a dedicated server, and Elasticsearch itself became multi-node with seed hosts and master election — plus an ILM policy across hot, warm and cold phases.
-
The pipeline moved off the application hosts onto a dedicated CI server, and JCasC defined the job itself rather than a person clicking through the Jenkins UI.
-
Twenty Ansible roles and three playbooks provisioned the environment, with tags allowing one component to be reprovisioned without re-running everything.
-
Ansible Vault across all seven group variable files, SMTP credentials included, so the secret store came from the same source of truth as the configuration.
-
cAdvisor ran as a managed container, which is what made per-container CPU and memory metrics available at all.
-
Carried over unchanged — and this is the stage where the load balancer stopped being where monitoring lived, as Prometheus moved off it onto the monitor server.
what broke here
- 3 fixed / 4
- 1 workaround / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
Filebeat ingested its own logs, and the loop filled the disk
`/var/log/syslog` reached 18GB and provisioning died with `No space left on device`. Logstash and Filebeat wrote to syslog, Filebeat shipped it back, Logstash logged about it.
then: The harvest paths are restricted to the specific logs intended for shipping, so the pipeline no longer reads its own output.
- fixed
Elasticsearch was killed by the JVM heap it asked for
`There is insufficient memory for the Java Runtime Environment to continue.`
then: Heap sizes and VM resources were adjusted. The archived monitor has 6 GiB and 5 vCPU, with Elasticsearch and Logstash heaps set to 1g. The later X-Pack startup issue is recorded separately.
- workaround
Then Kibana starved, and then everything starved at once
`uptime` showed load average 45.55 with all vCPUs pinned, while every Filebeat agent shipped its whole backlog simultaneously and all three ELK components ran their own initialisation.
then: Mitigated by provisioning order and heap sizing rather than eliminated. Every agent still ships its backlog at once on a cold start, and that is what pins the vCPUs.
- fixed
Three community Grafana dashboards failed against this cAdvisor version, one after another
ID 14282 queried metric names this cAdvisor does not provide; ID 13946 did arithmetic between metrics whose labels do not match one-to-one.
then: Resolved by reading the candidate dashboards' JSON from the Grafana API and matching their PromQL against the metric names this cAdvisor actually emits. ID 193 was compatible and is provisioned, and three hand-built dashboards now cover the VM, container and application panels directly.
4 of 9 — the project page has the rest
what it taught
- A node's usable memory is its allocatable memory, and the difference decides whether an alert is meaningful or permanent
- High-level modules do too much internally. Writing the apt sources file directly avoided the lock contention the module caused.
- Importing a saved object idempotently is harder than not checking, because Kibana's background cleanup reports a deleted dashboard as still existing
- 05 / 7 2025-08-04 → 2025-09-26 124 commits
Cluster Chronicles project page →
Six VMs replaced by one cluster, and a NetworkPolicy that did nothing.
topology · 1 cluster
A single Minikube cluster on the Docker driver, with Calico in place of the default kindnet, Helm-managed Prometheus and ELK, and in-cluster Jenkins.
inherited
- Six VMs of application, CI/CD and observability
- The application itself, unchanged apart from a moved metrics port
concepts in play
10 of 10 introduced-
Six VMs became one Minikube cluster, and connect.sh applied namespaces, RBAC, storage, workloads, an autoscaler, three NetworkPolicies and two Helm releases in dependency order.
-
A ClusterIP Service gave the backend a stable internal DNS name, replacing addresses that had to be known in advance and had to change whenever the topology did.
-
The backend gained readiness and liveness probes on /healthz, and kubectl rollout status made the pipeline wait for the rollout to complete rather than assume it had.
-
Two replicas plus kube-proxy and the nginx ingress controller replaced the hand-maintained upstream block entirely.
-
The Prometheus operator stack arrived via Helm with a ServiceMonitor scraping metrics every 15 seconds, custom alerting rules, and an EFK stack managed the same way.
-
Jenkins itself ran inside the cluster as an ephemeral pod agent, with RBAC-scoped service accounts limiting what the pipeline could reach.
-
Imperative kubectl create commands gave way to declarative YAML and Helm values, which made the entire cluster state reviewable as a diff.
-
Kubernetes Secrets replaced vaulted variables, consumed through secretKeyRef and excluded from git — narrower access control, but base64 rather than encryption.
-
Docker stopped being the deployment target and became the substrate: Minikube runs on top of it, and the Dockerfiles moved to a sibling repository.
what broke here
- 3 fixed / 4
- 1 workaround / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
A NetworkPolicy that silently did nothing at all
An HTTP response came back from an unlabelled pod when a timeout was expected. Minikube's default kindnet does not support NetworkPolicy.
then: Minikube is now started with `--cni=calico`, which enforces policies. kindnet accepts the manifest and ignores it, so nothing failed and nothing worked.
- fixed
After switching to Calico, the same policy worked too well and took down the application
`app.local` returned `504 Gateway Time-out`. Simulating the ingress controller from inside `ingress-nginx` proved the policy was blocking it.
then: Ingress controller namespace exempted from the policy, found by reproducing the request from inside the controller rather than from a laptop.
- fixed
Custom alerting rules reverted to old values minutes after being applied, and the first diagnosis was wrong
The initial hypothesis, a Helm chart name collision, was disproved by experiment. The real cause: Jenkins, configured via JCasC to run on startup, checked out main and re-applied the old committed manifests.
then: The startup pipeline was identified as the writer applying the older committed files. Local deployment and CI must use the same committed manifests; ArgoCD ownership is introduced in the subsequent GitOps stage.
- workaround
A memory alert that could never fire, because the arithmetic compared two different truths
Kubernetes reported 16GiB allocatable; the Docker cgroup capped at 10GiB. The maximum expressible value was 10/16 = 62.5%, so a 90% threshold was unreachable.
then: Divided by `10737418240` explicitly, which makes it fire on Docker Desktop. That is a second hardcoded constant rather than a fix. It is right for the machine this ran on and wrong on a real node.
4 of 8 — the project page has the rest
what it taught
- Declarative systems can fail silently. Helm did not error when it could not read a dashboard file; it created an empty resource
- Verify the actual live labels on a running pod; selectors are exact and literal
- When two automation systems can touch the same resources, they must share one source of truth
- Shared infrastructure like the ingress controller must be health-checked after any node-level resource event
- 06 / 7 2025-08-19 → 2025-11-12 693 commits
GitOps Galaxy project page →
Git becomes the only thing allowed to change the cluster.
topology · 1 cluster
The same cluster, now managed by ArgoCD App-of-Apps: 12 Application manifests including the root, with destinations across argocd, database, monitoring, jenkins, dev, vault and vault-operator.
inherited
- The Cluster Chronicles cluster and its deployed application
- A working Jenkins pipeline, which now becomes one of two deployment triggers
concepts in play
10 of 10 introduced-
Escalated from declarative to reconciled: Git became the deployment record, with eleven child Application manifests under the root app and automated prune plus selfHeal.
-
HashiCorp Vault with the Vault Secrets Operator replaced base64 Kubernetes Secrets, and a VaultStaticSecret rotates the database credentials every 30 seconds. This is the gap stage 5 left open, closed properly.
-
Probes became templated Helm values, and argocd app wait --health made ArgoCD itself the gate the pipeline blocks on.
-
Monitoring became a GitOps Application with sync-waves for ordering, so an alert rule detecting a vanished target ships through the same path as everything else.
-
Jenkins stopped deploying to the cluster at all — it commits to Git and ArgoCD reconciles from there. ArgoCD Image Updater tracks semver tags.
-
A headless Service resolves under the name of the resource it fronts, not the service's own name. The StatefulSet database refused to connect until that was understood.
-
The nginx Ingress Controller became a declared class with host-based routing across six entry points, replacing the per-tool proxy configuration files.
-
Everything moved into a Helm chart, so replicas, probes and ingress class became values rather than templates.
-
Docker is only the Minikube driver now. The Dockerfiles live in a sibling application repository — which is where this arc stopped building its own images.
what broke here
- 1 fixed / 2
- 1 workaround / 2
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
Application resources raced each other for CRDs that did not exist yet
`monitoring-custom-config` tried to create ServiceMonitors before `monitoring-stack` had created the CRDs. Fixed with sync waves: 0 for the stack, 1 for the config.
then: Sync waves: `0` for the monitoring stack and `1` for its configuration, declared in `argocd/app-monitoring-custom.yaml`.
- workaround
Rollback experiments conflicted with automatic reconciliation
The history contains `ci: Disable root selfHeal during rollback` and repeated ROLLBACK commits that pause or disable Image Updater.
then: The experiments coordinated root self-heal with pausing Image Updater to prevent recovery actions from being undone. The archived root manifest enables self-heal; this is a workflow constraint in this setup, not an inherent incompatibility between GitOps and rollback.
2 of 8 — the project page has the rest
what it taught
- The root app reconciles child Application manifests; each child reconciles its own workload. Those are separate control loops
- Generated TLS material needs a restart check as well as a successful initial deployment
- Least privilege has to be proven, not asserted
A test script authenticates with the image-updater user token and confirms sync is refused while get is allowed.
- 07 / 7 2025-11-07 → 2026-10-06 49 commits
Cloud Cartographer project page →
The same application, priced. The database was the whole story.
topology · 1 host
No live cloud spend. A load-testing harness, a monitoring stack, and two pricing calculators.
inherited
- The same sample application, measured rather than rebuilt
concepts in play
10 of 10 introduced-
Prometheus with Loki, Promtail, Blackbox Exporter, docker-stats and postgres-exporter, four dashboards and 15-day metric retention — the whole stack reproducible from a Compose file.
-
Blackbox Exporter probed the health endpoint with the http_2xx module, chosen specifically so the application needed no code change to become observable.
-
Compose with an explicit linux/amd64 build target, plus a postgres healthcheck using pg_isready so dependent services wait rather than race.
what broke here
- 1 fixed / 3
- 2 unresolved / 3
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- unresolved
PostgreSQL consumed far more CPU than the workload could explain
P95 3.40 vCPU at 100 users while the entire test generated only about 6.8 MB of backend egress.
then: Never explained. The measurement stands as recorded, and sizing conservatively on it is the decision: a 48-vCPU instance priced from a number nobody can account for.
- fixed
Two of the four hypotheses were ruled out by reading the application’s own source
`email` is declared `TEXT UNIQUE NOT NULL`, and `sql.Open("pgx", ...)` returns a persistent pool. These contradict the hypotheses of an absent email index or absent pool; they do not establish the CPU anomaly's cause.
then: Both were eliminated on inspection rather than by experiment, so two of the four hypotheses were dropped without a reproduction.
- unresolved
The root cause remains unidentified
What survives: default container database configuration, a measurement artefact in the exporter, and background maintenance overhead such as autovacuum and checkpointing.
then: Still unidentified, and carried forward as the number one post-migration risk rather than closed with a guess.
3 of 4 — the project page has the rest
what it taught
- A cost model must carry measurement uncertainty rather than treat its sizing assumptions as validated capacity
- A post-optimisation saving is a hypothesis until the cause is confirmed, and the write-up now says so
- An instrument you cannot trust should be isolated on a known-good machine before you trust its replacement
the through-line
What makes it one story
Imperative to declarative. Pull-based to push-based. Hand-configured to
self-reconciling. On-prem to cost-modelled. Each stage inherits the last
one's constraints — including one that is purely cosmetic in retrospect:
the IP range moved from 192.168.56.10–13 to
192.168.56.20–25 between stages 1 and 2 because
known_hosts collisions broke when both environments existed
at once.
Nobody puts that in a CV. It is the kind of unglamorous constraint that separates a real engineering history from a curated one — and every stage here documents its failures for the same reason.
all projects → Juho