Skip to content

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.

all 20 concepts →

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.

src/content/data/devops-arc.ts · DEV_OPS_ARC

client:load

Reconstruction of the documented topologies, not a live system.

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.

  1. 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.

    • A hand-written nginx upstream split traffic across two web servers, which is where the idea of a redundant backend stopped being theoretical.

    • 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.

    1. 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.

    2. 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.

    3. 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.

    4. 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
  2. 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.

    • 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.

    1. 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.

    2. 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.

    3. 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.

    4. 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.

  3. 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.

    1. 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.

    2. 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.

    3. 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
  4. 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
    • 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.

    1. 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.

    2. 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.

    3. 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.

    4. 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
  5. 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.

    1. 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.

    2. 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.

    3. 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.

    4. 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
  6. 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.

    1. 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`.

    2. 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.

  7. 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.

    1. 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.

    2. 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.

    3. 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