Automation Alchemy
Six virtual machines provisioned entirely by Ansible, including a Jenkins server configured as code.
- Repeat lab provisioning reached changed=0.
- Application deployment and scheduled backups automated.
Explore how these systems fit together. Each animation reconstructs a documented mechanism; it is not a live deployment. Open a card to step through the flow, then explore the project for the decisions, results and limitations.
Six virtual machines provisioned entirely by Ansible, including a Jenkins server configured as code.
Automation Alchemy: one command, six machines
vagrant up → runs the playbook itself
192.168.56.20–.25 · forwarded ssh :2222–:2227
upstream frontend_app_servers no balancing directive is set
.21:3000 web-2 .22:3000
Six requests, strictly alternating — nginx's default round-robin. The app renders
the host it got in Served by:, which is how this was checked.
upstream with a single server, so there is nothing
to balance
/api/metrics /metrics
rewrite ^/api/(.*)$ /$1 DOCKER-USER — UFW's input chain does not govern Docker's
forward chain
ssh, then docker pull / run on each
0 2 * * * on every one of these
backupusr@backup-server /srv/backups/<hostname>/
Cron runs as devops and elevates to root with sudo for
rsync; the remote side is backupusr. Config only — no volumes, database,
uploads or logs.
--delete makes this a mirror rather than a backup: one copy per host,
overwritten nightly. A file removed on a host is removed here the same night, and
there is no earlier copy to fall back to. Nothing prunes it because nothing is ever
kept.
prometheus :9090 on load-balancer — alongside nginx, Grafana and tinyproxy, so the machine that loses the site also loses the monitoring
Every 15 seconds, 30-day retention, node_exporter on all six hosts. Grafana
gets one dashboard — a community one, fetched at deploy time, not committed here.
Nothing alerts. No Alertmanager is installed anywhere, and rule_files and
alerting are commented-out placeholders — a host could die at three in the
morning and no one would be told.
The app's own /metrics endpoint — the one nginx rewrites to in scene 2 —
is never scraped, and node_exporter carries no per-request data. Nothing
here can show whether traffic is still reaching both frontends.
A full observability platform. Prometheus, Grafana, Alertmanager and ELK, deployed from twenty Ansible roles.
Sherlock Logs: six machines, one of them watching
vagrant up → runs the playbook itself
192.168.56.20–.26 · forwarded ssh :2222–:2225, :2227, :2228
On 9 July 2025 those three moved here and backup-server was
commented out — which is why :2226 is missing from the port list above, and why
the last two rows are empty. In Automation Alchemy they ran on the load balancer, so losing
a host took the monitoring with it.
The third tier is the one the previous figure admitted it did not have: an application
exposing /metrics that nothing ever asked for.
Nothing ever deletes them. There is no index lifecycle policy, so metrics expire at thirty days and logs never do.
Most alerting tells you something broke and then goes quiet, which is indistinguishable from the system healing on its own.
Six virtual machines replaced by one Kubernetes cluster, declared in manifests and Helm charts.
Cluster Chronicles: six machines, and one path through them
./connect.sh → in order, because each step waits on the last
minikube start --cni=calico --cpus=4 --memory=10240 --driver=docker minikube addons enable ingress kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=controller --timeout=120s kubectl apply namespaces/ rbac/ kubectl apply alertmanager-secret.yml before Prometheus, to avoid a race helm upgrade --install prometheus installs the CRDs kubectl wait --for=condition=ready pod -l …name=prometheus-operator --timeout=120s kubectl apply application/ Ingress and ServiceMonitors
One node, Docker driver, Calico named on the command line because it enforces NetworkPolicy. The rest of the script adds Jenkins, Elasticsearch, Kibana
and Fluent Bit.
One host and one ingress object, two paths. The service has pods behind it — two of the frontend, one of the backend — and the next stages are about what keeps those two numbers honest.
backend-allow-ingress · namespace: default · policyTypes: [Ingress]
app: frontend a podSelector alone — same namespace needs nothing else app.kubernetes.io/name: prometheus across namespaces, so it needs both selectors kubernetes.io/metadata.name: ingress-nginx the whole namespace — no pod label needed [Ingress] only — the rule set governs what reaches the backend, and says
nothing about what the backend reaches.
Three rules and no catch-all, so anything not listed is denied. The second one exists for the scraper in the last stage.
HPA · frontend-deployment
minReplicas: 2 · maxReplicas: 10 · target: 50% average CPU
scaleTargetRef frontend-deployment minReplicas 2 maxReplicas 10 target averageUtilization: 50 · CPU the floor the Deployment itself sets replicas: 2 average CPU
pods · frontend-deployment
An autoscaler needs the metrics API, so step 2 of the deploy enables metrics-server — without it this controller has nothing to watch.
The controller watches one number and moves one other. Each half of this is a drawing of the same loop: the meter crossing the dashed line is the row gaining a pod.
Metrics
Logs
Metrics are pulled on 9091; logs are pushed from every node. One is the
policy’s second rule, the other never passes through it.
The two columns are not independent: the exporter turns Elasticsearch's own health into
metrics, which is what two of the twelve rules read. The Prometheus Operator claims objects
by label rather than by namespace, so the Fluent Bit ServiceMonitor gets release=prometheus added to it — after the deploy waits for it to exist.
A modular-monolith e-commerce backend and React frontend, with an asynchronous payment pipeline.
Full-Stack E-Commerce Platform: a 202 before the work is done
A Go and React matchmaking app with preference scoring, profile setup and realtime chat.
match-me: the same score, and two different decisions
No shared game
never scored
Gates cleared, little in common
4.0 Both gates passed, and it still only earns the entry fee
They liked you first
14.5 One attribute disagrees — you want Ranked, they play Competitive
Requested Accepted the second like is the accept, and a chat row is created with it
Sent over POST /chats/:id/messages, not the socket. The socket carries it
out, with chat_id, sender_id, content and
created_at.
A book-discussion forum in Go. Server-rendered on SQLite, with no JavaScript.
Literary Lions Forum: one binary, one session row, and a 303
docker build -t forum-app .
FROM golang:1.23.0 WORKDIR /app RUN go mod download RUN go build -o main . EXPOSE 8080 CMD ["./main"] internal/ is 2,117 lines and main.go is 67. Seven of the eight
templates are parsed once into package-level vars at startup, so a request renders from
memory; the eighth is read from disk again on every visit.
The redirect target is the Referer header, so the handler sends you back to
whichever page you submitted from — falling back to / when there isn't one.
if userID <= 0 → invalid user ID
any leg fails → ROLLBACK, nothing is written
303 See Other → /posts
One post is several things at once: the row, then a link row per category, each carrying the id the row before it returned. Written together or not at all.
SELECT like_type FROM Likes WHERE user_id = ? AND post_id = ?
and the number on the page is not stored either — it is recomputed on every view:
SELECT COUNT(*) FROM Likes WHERE post_id = p.id AND like_type = 1 SELECT COUNT(*) FROM Likes WHERE post_id = p.id AND like_type = -1 One signed integer, one row per user per post. Pressing the same button twice changes nothing — it is a value you set, not a switch you flip.
in the UI
one boolean, {{if .LoggedIn}}, across all 8 templates
in the profile tab
the same rows the counters read, joined back to their posts
SELECT … FROM Likes INNER JOIN Posts WHERE Likes.user_id = ? AND like_type = 1
A vote is written once and read twice: once as a number on the post, and once as a list on the account that cast it.