In 08-02 TechCorp's system was completed as code: six services, a gateway, a library and their tests. This lesson takes it to a cluster and operates it. The first half answers "how do I bring all of this up from scratch?": the complete techcorp/platform repository, the local compose.yaml with the six services and all the infrastructure, the startup order on a fresh cluster, the script that applies it, the migration Jobs and the smoke test with a Keycloak token. The second half answers "and what do I do every day?": a normal end-to-end deployment of orders-service, the Black Friday campaign, an incident in a DLQ step by step, a secret rotation, the Node upgrade and a contract evolution applied to the whole system, plus a table of frequent operations and the approximate monthly cost.
We do not re-explain any YAML from 05-02 or any code: when a Deployment, a ServiceMonitor or an ExternalSecret shows up, we will point to the lesson where it was written and show only what changes when going from one service to six.
Contents
- The complete
techcorp/platformrepository compose.yaml: the whole system on a laptop- Startup order on a fresh cluster
scripts/deploy-all.shand the migrationJobs- Verification and smoke test
- Operations: a normal deployment of
orders-service - Operations: the Black Friday campaign
- Operations: an incident in
payments.stock.dlq, step by step - Operations: rotating a secret, upgrading Node and evolving a contract
- Quick runbook of frequent operations
- Approximate monthly cost and how to reduce it
- The complete
techcorp/platform repository
techcorp/platform repositoryIt is the Platform team's repository and the source of truth for production (Argo CD reads it, 05-03 §6). Everything we have been leaving in it throughout the course, organized:
techcorp/platform/
├── local/
│ ├── compose.yaml # 05-01, extended in section 2
│ ├── kind.yaml # 05-02 §4
│ └── keycloak/realm-techcorp.json # the realm from 07-01 §4, imported at startup
├── k8s/
│ ├── namespace.yaml # techcorp (PSA restricted labels, 07-04 §3)
│ ├── catalog-service/ orders-service/ inventory-service/ payments-service/
│ ├── notifications-service/ customers-service/ gateway/ bff-mobile/ analytics-consumer/
│ │ ├── base/ # kustomization, configmap, deployment, service, job-migrations, serviceaccount, servicemonitor, pdb,
│ │ │ # externalsecret(s), hpa | scaledobject (where applicable), networkpolicy (07-04)
│ │ └── overlays/dev|staging|prod/kustomization.yaml # replicas, newTag, ConfigMap patches
│ ├── network/ # 00-deny-all, 01-allow-dns, gateway, *-service.yaml (07-04 §6)
│ ├── infra/ # Helm values and third-party manifests (05-02 §11-12)
│ │ ├── rabbitmq/values-{dev,prod}.yaml, definitions.json (vhost techcorp, per-service users, queues, 07-02 §8)
│ │ ├── postgres/values-*.yaml, init/01-svc-schemas.sql # one svc_* user per service (02-04)
│ │ ├── mongo/ redis/ keycloak/ (realm import) ingress-nginx/ cert-manager/ (ClusterIssuer letsencrypt-prod, 07-02)
│ │ ├── external-secrets/ (ClusterSecretStore techcorp-vault, 07-04) keda/
│ │ └── observability/ kube-prometheus-stack, loki, promtail, otel-collector, jaeger (06-01, 06-02)
│ ├── slos/prometheusrule-slos-techcorp.yaml, alertmanager-config.yaml # 06-05
│ └── argocd/ app-of-apps.yaml + one Application per service and environment # 05-03 §6
├── observability/dashboards/red-per-service.json, orders-saga.json, queues.json # 06-01 §10
├── runbooks/ orders/, catalog/, common/dlq.md, common/circuit-open.md, platform/… # 06-05 §8
├── tests/e2e/*.e2e.test.js, tests/load/catalog.js (k6) # 04-05, 06-04
├── scripts/deploy-all.sh, smoke.sh, token-keycloak.sh # sections 4-5
├── .github/workflows/node-service-ci.yml (@v1, @v2) # 05-03 §11
├── SECURITY.md # 07-04 §10
└── README.md (map of this tree and "how to bring it up locally in 10 minutes")Two decisions hold the tree together: one base per service, one overlay per environment (05-02 §12: Inventory's base is Orders' with different names, port 3006 and its ScaledObject instead of an HPA), and third parties through Helm with values versioned here, never installed by hand. When the Payments team needs a new Secret, it opens a PR against this repository; nobody runs kubectl create secret in production.
compose.yaml: the whole system on a laptop
compose.yaml: the whole system on a laptopThe compose.yaml from 05-01 §8 had PostgreSQL, MongoDB, RabbitMQ, the seed, the Orders migrations, Catalog, Orders, the Customers stub and the gateway. The additions for the complete system, summarized (the new services follow exactly the orders-service pattern from 05-01: image + build, the variables from the config.js table in 08-02, depends_on with conditions, stop_grace_period: 15s):
# techcorp/platform/local/compose.yaml — ONLY what is added on top of 05-01 §8
services:
postgres: # now creates the five databases and the svc_* users at startup
volumes: [pg-data:/var/lib/postgresql/data, ../k8s/infra/postgres/init:/docker-entrypoint-initdb.d:ro] # 01-svc-schemas.sql
redis: { image: redis:7-alpine, healthcheck: { test: ["CMD", "redis-cli", "ping"] } }
keycloak:
image: quay.io/keycloak/keycloak:25.0
command: ["start-dev", "--import-realm"] # techcorp realm from 07-01: clients web-store, bff-mobile, *-service; user ana.ruiz
volumes: [./keycloak:/opt/keycloak/data/import:ro]
ports: ["8180:8080"]
environment: { KEYCLOAK_ADMIN: admin, KEYCLOAK_ADMIN_PASSWORD: admin } # local only
jaeger: { image: jaegertracing/all-in-one:1.60, ports: ["16686:16686"] } # UI; receives OTLP on 4317 (06-02)
prometheus: { image: prom/prometheus:v2.53.0, volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro], ports: ["9090:9090"] }
grafana: { image: grafana/grafana:11.1.0, ports: ["3000:3000"], volumes: [../observability/dashboards:/var/lib/grafana/dashboards:ro, ./grafana-provisioning:/etc/grafana/provisioning:ro] }
loki: { image: grafana/loki:3.1.0, ports: ["3100:3100"] }
# --- one-shot migrations, one per service with a DB (same image, different command; 05-01 §8) ---
inventory-migrations: { image: ghcr.io/techcorp/inventory-service:local, build: { context: ../../inventory-service, secrets: [npmrc] }, command: ["node","scripts/migrate.js"], environment: { INVENTORY_DB_URL: postgres://svc_inventory:dev-inventory@postgres:5432/inventory }, depends_on: { postgres: { condition: service_healthy } }, restart: "no" }
payments-migrations: { image: ghcr.io/techcorp/payments-service:local, build: { context: ../../payments-service, secrets: [npmrc] }, command: ["node","scripts/migrate.js"], environment: { PAYMENTS_DB_URL: postgres://svc_payments:dev-payments@postgres:5432/payments }, depends_on: { postgres: { condition: service_healthy } }, restart: "no" }
notifications-migrations: { image: ghcr.io/techcorp/notifications-service:local, build: { context: ../../notifications-service, secrets: [npmrc] }, command: ["node","scripts/migrate.js"], environment: { NOTIFICATIONS_DB_URL: postgres://svc_notifications:dev-notif@postgres:5432/notifications }, depends_on: { postgres: { condition: service_healthy } }, restart: "no" }
customers-migrations: { image: ghcr.io/techcorp/customers-service:local, build: { context: ../../customers-service, secrets: [npmrc] }, command: ["node","scripts/migrate.js"], environment: { CUSTOMERS_DB_URL: postgres://svc_customers:dev-customers@postgres:5432/customers }, depends_on: { postgres: { condition: service_healthy } }, restart: "no" }
# --- new services (orders-service pattern from 05-01; variables from 08-02) ---
inventory-service: # PORT 3006, INVENTORY_DB_URL, RABBITMQ_URL, RESERVATION_TTL_S 900, ORDERS_URL, OTEL_*; depends_on postgres, rabbitmq, inventory-migrations
payments-service: # PORT 3003, PAYMENTS_DB_URL, RABBITMQ_URL, PAYMENT_PROVIDER_URL http://fake-payment-provider:4000, PAYMENT_PROVIDER_API_KEY dev, PAYMENT_NEW_PROVIDER "false"
notifications-service: # PORT 3005, NOTIFICATIONS_DB_URL, RABBITMQ_URL, EMAIL_PROVIDER console
customers-service: # REPLACES the stub from 04-04: build ../../customers-service; CUSTOMERS_DB_URL, RABBITMQ_URL, KEYCLOAK_URL http://keycloak:8080, KEYCLOAK_REALM techcorp, KEYCLOAK_ADMIN_CLIENT_SECRET dev
fake-payment-provider: # payments-service/tests/doubles/fakePaymentProvider.js: 200 except tok_decline (402) and tok_503 (503); Idempotency-Key honored
gateway: # + KEYCLOAK_ISSUER http://keycloak:8080/realms/techcorp, KEYCLOAK_AUDIENCE techcorp-api; no MONOLITH_URL (removed in 08-01)
# all services: OTEL_EXPORTER_OTLP_ENDPOINT http://jaeger:4317, OTEL_TRACES_SAMPLER_ARG "1.0"With this, docker compose up -d --wait brings up some twenty containers (seventeen running plus the one-shot tasks) in about 90 seconds on an ordinary laptop, and the four E2E tests from 08-02 §9 pass against http://localhost:8080 with a token obtained from http://localhost:8180. It is the environment with which the learner can reproduce the whole course (08-04).
- Startup order on a fresh cluster
Whether it is a kind cluster (platform/local/kind.yaml, 05-02 §4) or a managed one, the order matters: each layer depends on the previous one, and several pieces (ESO, cert-manager, KEDA, the Prometheus operator) install CRDs that later manifests use.
| Step | What | How | Depends on | Lesson |
|---|---|---|---|---|
| 1 | Namespaces techcorp (PSA restricted), observability, infra, argocd |
kubectl apply -f k8s/namespace.yaml |
— | 05-02, 07-04 |
| 2 | ingress-nginx, cert-manager (+ ClusterIssuer letsencrypt-prod), External Secrets Operator (+ ClusterSecretStore techcorp-vault), KEDA, kube-prometheus-stack |
helm upgrade --install with k8s/infra/*/values-<env>.yaml |
1 (and CRDs among themselves: ESO before any ExternalSecret) |
05-02 §11-12, 06-04, 07-02, 07-04 |
| 3 | RabbitMQ (vhost techcorp, users orders, inventory, payments, notifications, customers, analytics; TLS 5671; definitions.json with per-queue permissions), PostgreSQL (init/01-svc-schemas.sql: five DBs, five svc_* users), MongoDB, Redis |
Helm (dev) / managed (prod), credentials in the secrets manager → ExternalSecret |
2 (ESO) | 02-04, 05-02, 07-02 |
| 4 | Keycloak with the techcorp realm imported (clients, roles, scopes, customerId mapper) |
Helm + realm ConfigMap |
3 (Keycloak's PostgreSQL) | 07-01 |
| 5 | Loki + Promtail, otel-collector, Jaeger; PrometheusRule slos-techcorp; Alertmanager per team; dashboards |
kubectl apply -k k8s/infra/observability |
2 | 06-01, 06-02, 06-05 |
| 6 | NetworkPolicies deny-all + DNS + per service |
kubectl apply -f k8s/network/ |
1 | 07-04 §6 |
| 7 | Each service's ExternalSecret (*-db, *-rabbitmq, payments-provider, notifications-email, customers-keycloak, gateway-keycloak) |
They live in each service's base/; they sync before the Deployment |
2, 3 | 07-04 §4 |
| 8 | Services in saga order: customers-service and catalog-service (Orders' synchronous dependencies), inventory-service, payments-service, notifications-service, orders-service, analytics-consumer, bff-mobile, gateway |
kubectl apply -k k8s/<service>/overlays/<env> (or Argo CD) |
3-7 | 05-02 |
| 9 | Ingress api.techcorp.example → gateway, api-techcorp-tls certificate |
In the gateway's base | 2, 8 | 05-02 §9, 07-02 §3 |
About step 8: the "saga order" is not strictly necessary —services start even if a collaborator is missing (/health/ready does not check Catalog or Customers, 04-04 §9) and durable queues hold messages— but deploying the consumers first and Orders and the gateway last prevents the first orders of the smoke test from waiting for someone to declare inventory.orders.
scripts/deploy-all.sh and the migration Jobs
scripts/deploy-all.sh and the migration JobsIn dev and in a test cluster everything is applied with a script; in production steps 8-9 are done by Argo CD (app-of-apps.yaml), and the script is only used for the infrastructure of steps 1-7 (or Terraform/Helmfile, if Platform prefers).
#!/usr/bin/env bash
# techcorp/platform/scripts/deploy-all.sh <environment: dev|staging> — idempotent: it can be rerun in full
set -euo pipefail
ENV="${1:?environment}"; NS=techcorp
cd "$(dirname "$0")/.."
echo "== 1. namespaces"; kubectl apply -f k8s/namespace.yaml
echo "== 2. operators and CRDs" # helm upgrade --install is idempotent
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null; helm repo add jetstack https://charts.jetstack.io >/dev/null
helm repo add external-secrets https://charts.external-secrets.io >/dev/null; helm repo add kedacore https://kedacore.github.io/charts >/dev/null
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts >/dev/null; helm repo add bitnami https://charts.bitnami.com/bitnami >/dev/null; helm repo update >/dev/null
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx -n infra -f k8s/infra/ingress-nginx/values-$ENV.yaml --wait
helm upgrade --install cert-manager jetstack/cert-manager -n infra --set crds.enabled=true --wait && kubectl apply -f k8s/infra/cert-manager/
helm upgrade --install external-secrets external-secrets/external-secrets -n infra --wait && kubectl apply -f k8s/infra/external-secrets/ # ClusterSecretStore
helm upgrade --install keda kedacore/keda -n infra --wait
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack -n observability -f k8s/infra/observability/prometheus-values-$ENV.yaml --wait
echo "== 3. data and messaging" # in prod: managed; the script only applies the ExternalSecrets
helm upgrade --install rabbitmq bitnami/rabbitmq -n infra -f k8s/infra/rabbitmq/values-$ENV.yaml --set-file loadDefinition.definitions=k8s/infra/rabbitmq/definitions.json --wait
helm upgrade --install postgres bitnami/postgresql -n infra -f k8s/infra/postgres/values-$ENV.yaml --set-file primary.initdb.scripts."01-svc-schemas\.sql"=k8s/infra/postgres/init/01-svc-schemas.sql --wait
helm upgrade --install mongo bitnami/mongodb -n infra -f k8s/infra/mongo/values-$ENV.yaml --wait
helm upgrade --install redis bitnami/redis -n infra -f k8s/infra/redis/values-$ENV.yaml --wait
echo "== 4. identity"; helm upgrade --install keycloak bitnami/keycloak -n infra -f k8s/infra/keycloak/values-$ENV.yaml --wait # imports realm-techcorp.json
echo "== 5. observability"; kubectl apply -k k8s/infra/observability/ && kubectl apply -f k8s/slos/
echo "== 6. network"; kubectl apply -f k8s/network/
echo "== 7-8. services in saga order"
for s in customers-service catalog-service inventory-service payments-service notifications-service orders-service analytics-consumer bff-mobile gateway; do
echo " -> $s"
kubectl -n $NS delete job "$s-migrations" --ignore-not-found # a Job is immutable: it is deleted and recreated (05-03 §5)
kubectl apply -k "k8s/$s/overlays/$ENV" # ExternalSecret, ConfigMap, Job, Deployment, Service, PDB, HPA/ScaledObject, ServiceMonitor, NetworkPolicy
if kubectl -n $NS get job "$s-migrations" >/dev/null 2>&1; then # only services with a DB have a Job
kubectl -n $NS wait --for=condition=complete "job/$s-migrations" --timeout=180s
fi
kubectl -n $NS rollout status "deploy/$s" --timeout=180s # do not continue until it is Ready
done
echo "== 9. ingress"; kubectl -n $NS get ingress,certificate
echo "OK: $(kubectl -n $NS get pods --no-headers | grep -c Running) pods Running in $NS"Every service with a database carries its migrations Job in base/ (05-02 §6) with argocd.argoproj.io/hook: PreSync for production (05-03 §6): orders-service-migrations, inventory-service-migrations, payments-service-migrations, notifications-service-migrations, customers-service-migrations (Catalog has catalog-service-seed only in dev; Notifications does have a DB for deliveries). All of them run node scripts/migrate.js from the service image and are idempotent, which is why the delete + apply is safe. And all are expand-only (05-04 §3): the script never needs to "wait until no old pod remains" to apply a migration.
- Verification and smoke test
kubectl -n techcorp get pods # all Running and READY 1/1 (or 2/2 with an otel sidecar, if there were one); Jobs Completed
kubectl -n techcorp get externalsecret # SecretSynced / READY True on all of them
kubectl -n techcorp get hpa,scaledobject,pdb # catalog 2/20; inventory 2/10; PDB minAvailable 1 on all
kubectl -n techcorp get networkpolicy | wc -l # 11: deny-all, dns, gateway, 6 services, bff, analytics
for s in customers catalog inventory payments notifications orders; do
kubectl -n techcorp exec deploy/$s-service -- wget -qO- http://localhost:$(kubectl -n techcorp get svc $s-service -o jsonpath='{.spec.ports[0].port}')/health/ready
done # {"status":"ok","checks":{"postgres":"ok","rabbitmq":"ok"}} on each oneThe smoke test is the E2E from 04-05 done by hand through the Ingress, with a real token. scripts/token-keycloak.sh obtains one for the test user with the password grant (enabled only on the e2e-tests client of the dev/staging realm; in production that client does not exist, 07-01 ex. 1):
TOKEN=$(scripts/token-keycloak.sh https://auth.techcorp.example techcorp e2e-tests ana.ruiz 'test-password') # JWT with customerId=c-1024, customer role
API=https://api.techcorp.example/api/v1
curl -sf "$API/products?ids=p-501,p-777" | jq -r '.data[].name' # public: BT X200 Headphones / USB-C Cable 2 m
ORD=$(curl -sf -X POST "$API/orders" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H "Idempotency-Key: smoke-$(date +%s)" \
-d '{"customerId":"c-1024","lines":[{"productId":"p-501","quantity":1},{"productId":"p-777","quantity":2}],"shippingAddress":{"street":"Gran Vía 12","postalCode":"28013","city":"Madrid","country":"ES"}}' | jq -r .id)
echo "order $ORD" # ord-… (202)
for i in $(seq 1 15); do S=$(curl -sf "$API/orders/$ORD" -H "Authorization: Bearer $TOKEN" | jq -r .status); echo "$i: $S"; [ "$S" = CONFIRMED ] && break; sleep 1; done
# 1: PENDING 2: STOCK_RESERVED 3: STOCK_RESERVED 4: CONFIRMED ← the full saga with the six real services
curl -s "$API/orders/ord-0000" -H "Authorization: Bearer $TOKEN" -o /dev/null -w '%{http_code}\n' # 404 (does not exist: same code as "someone else's", 07-01)
curl -s "$API/orders/$ORD" -o /dev/null -w '%{http_code}\n' # 401 without a tokenAnd the three observability checks, which are the reason modules 6 and 7 were built: (1) in Jaeger (kubectl -n observability port-forward svc/jaeger-query 16686), search service=gateway, tag orderId=$ORD: one trace with the spans of the gateway, Orders, Customers, Catalog, PostgreSQL, and —linked via links from the outbox traceparent (06-02 §6)— Inventory, Payments (with the payment provider span) and Notifications; (2) in Grafana, "Orders saga" dashboard: orders_created_total +1, saga_duration_seconds with one observation of ~3 s, outbox_pending at 0 in the four services with an outbox; (3) in Loki, {namespace="techcorp"} | json | orderId="ord-…" returns the lines from the six services sorted by time, with the same requestId in the synchronous part.
If the status gets stuck at STOCK_RESERVED, the table in section 10 says where to look (almost always: payments.stock with no consumer, or a wrong PAYMENT_PROVIDER_URL in the Payments ConfigMap).
- Operations: a normal deployment of
orders-service
orders-serviceLuis merges a PR that adds customer cancellation (exercise 1 of 08-02). What happens, with each piece in its lesson:
flowchart LR
PR[PR in orders-service] --> CI[ci.yml → node-service-ci.yml@v1<br/>lint, unit, component, Testcontainers integration,<br/>consumer pacts published]
CI --> IMG[image ghcr.io/…/orders-service:sha-4b7e9c1<br/>Trivy, cosign, SBOM]
IMG --> STG[cd.yml: staging<br/>migrations Job 007, rollout, E2E, record-deployment]
STG --> CID[can-i-deploy orders-service sha-4b7e9c1 --to-environment prod<br/>do Catalog, Customers, Inventory, Payments, Notifications verify?]
CID --> TAG[git tag v1.6.0 → same image tagged 1.6.0]
TAG --> PRP[PR in platform: overlays/prod newTag 1.6.0<br/>human review: it is Orders]
PRP --> ARGO[Argo CD sync<br/>PreSync: migrations Job]
ARGO --> CAN[Ingress orders-canary weight 10%<br/>+ X-Canary for the team]
CAN --> OBS[30 min: OrdersErrorBudget burn rate,<br/>RED canary vs stable, saga_duration]
OBS -->|good| FULL[weight 100% → stable Deployment 1.6.0, canary to 0]
OBS -->|bad| BACK[canary-weight 0 + revert the commit]
| Step | Tool | Typical time | Lesson |
|---|---|---|---|
| Full CI with Testcontainers and pact publishing | GitHub Actions, node-service-ci.yml@v1 |
6 min | 04-05, 05-03 §3, §11 |
| Image, Trivy (blocks CRITICAL/HIGH), cosign, SBOM | docker/build-push-action, aquasecurity/trivy-action, sigstore/cosign |
3 min | 05-01 §5, 07-04 §2, §9 |
Staging: kustomize edit set image, Job 007, rollout status, E2E, record-deployment |
cd.yml |
4 min | 05-03 §5 |
can-i-deploy to prod: the five consumers/providers have verified this version |
Pact Broker | seconds | 05-03 §4, 08-02 §9 |
Tag v1.6.0 (same image); promotion PR in platform; human review (Orders and Payments require it) |
peter-evans/create-pull-request, review by Luis or a peer |
10-60 min (person) | 05-03 §7-8 |
Argo CD applies overlays/prod: PreSync with orders-service-migrations (007-customer-cancellation-expand.sql: cancelled_by column, DEFAULT NULL), then the canary Deployment |
Argo CD | 2 min | 05-03 §6, 05-04 §3 |
Canary at 10% for 30 min watching slo:orders_error_ratio by version (the service.version label from 06-02) and the canary's latency versus the stable one |
Ingress NGINX canary-weight, Grafana |
30 min | 05-04 §5, 06-05 §5 |
100%: stable newTag to 1.6.0, canary-weight: 0 |
PR (or Argo Rollouts, when it arrives) | 3 min | 05-04 |
Total: about 50 minutes of wall-clock time, of which about 12 are machine time; the rest is human review and canary observation, and they are deliberate. Compared with the Thursday night from 01-05 (a two-hour deployment, four rollbacks out of twelve), this happens at 11 in the morning on a Tuesday, eleven times a day across the set of services, and if the burn rate rises during the canary, canary-weight: 0 leaves 100% on the stable one in seconds: an MTTR of minutes without anyone typing a kubectl by hand.
- Operations: the Black Friday campaign
The campaign is prepared with a checklist that Platform and the four teams go over three weeks ahead (the first version was written for the 2025 Black Friday, 08-01 §3; today it is a templated issue in platform):
| When | What | How | Lesson |
|---|---|---|---|
| T−3 weeks | k6 load test on staging at ×20 browsing and ×4 orders; requests, pools and indexes get fixed |
tests/load/catalog.js and orders.js |
06-04 §5 |
| T−1 week | Temporary overlay overlays/prod-bf/: Catalog minReplicas: 6 (HPA up to 20), Inventory minReplicaCount: 4 (KEDA up to 10), Orders and Payments 4 fixed replicas, gateway 4; PDBs reviewed; Cluster Autoscaler with spare nodes |
PR in platform, Argo CD |
06-04 §2-4 |
| T−1 week | Error budget: check that every SLO has > 50% remaining; if Orders is below, the week goes to reliability, not features | SLO dashboard | 06-05 §11 |
| T−48 h | Deployment freeze except fixes (Argo CD keeps syncing, but promotion PRs are not merged); reinforced on-call with a secondary per team | Team rule, #platform-status |
06-05 §8 |
| T−24 h | Catalog cache pre-warmed; Redis TTL raised from 30 s to 120 s by flag; gateway rate limit tuned per route | ConfigMap + rollout restart of Catalog |
06-04 §6, 03-04 |
| D-day | War room: "RED per service", "Orders saga", "Queues" dashboards; SLO alerts with normal thresholds (not relaxed: if they burn, it is real) | Grafana, Alertmanager | 06-01, 06-05 |
| D+3 | The prod-bf overlay is removed, the freeze is lifted, metrics and cost are reviewed, brief postmortem even if there was no incident |
PR, 30-minute meeting | 06-05 §10 |
What you see on D-day in a good year (like 2025): Catalog between 6 and 16 replicas following the browsing curve; Inventory at 4-7 by inventory.orders length; saga_duration_seconds p95 at 4 s (the payment provider is slower too); error budget consumed on the day: 6% of the monthly. And what the checklist prevents: a manual kubectl scale that Argo CD reverts three minutes later (05-03 §6, selfHeal: true), which is why campaign scaling also goes through a PR.
- Operations: an incident in
payments.stock.dlq, step by step
payments.stock.dlq, step by stepA Tuesday in September 2026, 15:20. Different from INC-2031 (poison message due to a bug): this time the cause is external and the tools from 06-03/06-05 already exist. Follow the timeline as if you were the on-call person:
- 15:31 — Alert
DlqHasMessages{queue="payments.stock.dlq"}(warning: 10 min with messages, 06-05 §12) reaches the Payments team channel; it wakes nobody up (it is not critical), but it is business hours and on-call acknowledges it and opens#inc-2047-payments-dlq. - 15:33 — Runbook
common/dlq: "how many, since when, what type?".kubectl -n infra exec rabbitmq-0 -- rabbitmqctl list_queues name messages | grep dlq→payments.stock.dlq 14. In the RabbitMQ console, all 14 arestock.reservedwithx-attempts: 5: they exhausted their retries, they are not poison. - 15:36 — Loki:
{app="payments-service"} | json | level="error" | line_format "{{.eventId}} {{.err.code}} {{.message}}"→DEPENDENCY_UNAVAILABLE payment-provider: timeoutsince 15:12;CircuitOpen{dependency="payment-provider"}has also been in warning since 15:15 (06-03 §4). Jaeger: thePOST payment-provider/chargesspan witherror=trueand exactly 5,000 ms: the payment provider is not responding, not declining. - 15:38 — Impact: "Orders saga" dashboard: 41 orders in
STOCK_RESERVEDfor more than 5 minutes; the watchdog will start cancelling them forPAYMENT_TIMEOUTat 10 (06-03 §9). Severity SEV2 (part of the flow broken, no workaround for the customer); a notice goes to#platform-status: "since 15:12 charges are not completing due to a problem at the payment provider; orders are being held; next update at 16:00". - 15:40 — Stabilize before understanding: the payment provider's status page confirms an incident. Payments' decision: temporarily raise the watchdog limit from 10 to 30 minutes (Orders'
WATCHDOG_LIMIT_MINConfigMap +rollout restart; express PR inplatformwith theincidentlabel, so that Argo does not revert it) and thus not cancel 41 orders with reserved stock because of someone else's 20-minute outage. Reservations expire at 15 min viaexpires_at… unlessRESERVATION_TTL_Sis raised too; it is raised to 2,400 s the same way. Two configuration changes, zero code. - 15:52 — The payment provider recovers. The breaker goes half-open and closes; the messages in
payments.stock.retry(the ones that still had attempts left) get charged on their own within 2 minutes. The 14 in the DLQ remain. - 15:55 — Reprocess:
kubectl -n techcorp run reprocess --rm -it --image=ghcr.io/techcorp/payments-service:1.4.2 --env-from=secret/payments-rabbitmq -- node scripts/reprocessDlq.js --queue payments.stock --max 50(06-03 §8): 14 messages go back to the queue withx-attempts: 0andx-reprocess;chargeOrderfinds themIN_PROGRESS(step 1 of 08-02 §4) and looks up by idempotency key before charging: 3 had already been charged on the first attempt (the payment provider charged and the timeout fired before the response) and are markedCAPTUREDwithout charging twice; 11 get charged now.payment.confirmed×14, sagas closed. - 16:02 — Dashboard: 0 old orders in
STOCK_RESERVED; the two configuration changes are reverted (PR back); the incident is closed. Duration: 50 min from the first failure, 31 from the alert; 0 orders cancelled, 0 duplicate charges. - Brief postmortem (SEV2, 06-05 §10): external cause; everything designed worked (retries, breaker, DLQ, alert, runbook, payment provider idempotency); actions: (a) the
DlqHasMessagesalert took 10 min by design, butCircuitOpenfired at 3: add the step "raise the watchdog" to theCircuitOpen{payment-provider}runbook; (b) makeWATCHDOG_LIMIT_MINandRESERVATION_TTL_Shot-reloadable flags (04-03 §7) to avoid restarts; (c) ask the payment provider about its SLA. And the standing question: "which alert would have caught it earlier?" —CircuitOpenalready did; what was missing was the link between that alert and the action.
Compare with INC-2031 (40 min, 61 orders affected, 38 cancelled): same symptom ("the saga is not advancing"), half the impact and no destructive action, because the system already had deferred retries, DLQ, symptom-based alerts, a runbook and a script. It is exactly what 06-05 was written for.
- Operations: rotating a secret, upgrading Node and evolving a contract
Rotating orders-db. It is the procedure from 07-04 §4, run on the first Monday of each semester by Platform with the owning team: (1) new password for svc_orders in Vault (techcorp/prod/orders/db), with the svc_orders_b user so both are valid; (2) kubectl -n techcorp annotate externalsecret orders-db force-sync=$(date +%s) → the Secret changes; (3) kubectl -n techcorp rollout restart deploy/orders-service (rolling maxUnavailable: 0, 05-04) and rollout status; (4) orders-migrations is not needed; (5) revoke the old one and watch for password authentication failed in Loki for an hour. Fifteen minutes, no maintenance window; it is repeated for the *-db and *-rabbitmq of the six services on the same morning with a loop in scripts/rotate-secrets.sh. The audit_log of who rotated what is the PR's git log and Vault's audit log.
Node 20 → 22. Node 20 reaches end of maintenance in April 2026; the upgrade is done once in the template and propagates through Luis's rule: (1) node-service-template: FROM node:22-alpine in both stages of the Dockerfile (05-01 §3), engines.node: ">=22", @types/lint; (2) node-service-ci.yml@v2: actions/setup-node with node-version: 22 and a temporary [20, 22] matrix so that each service checks both before switching; (3) @techcorp/common-http is published and tested on 22 (05-03 §10); (4) each team, whenever it wants within a deadline (one month), opens a PR in its service with two lines: uses: …/node-service-ci.yml@v2 and the FROM; CI, Testcontainers, pacts and staging validate; production with the usual strategy (canary in Orders and Payments, rolling elsewhere); (5) after the month, @v1 is retired. Six two-line PRs instead of a coordinated migration; and if Notifications is a week late, nobody else waits.
Products v2 with price: { amount, currency }. It is the breaking change from 03-06 §10, now executed end to end:
| Week | Catalog (provider) | Consumers | Technique |
|---|---|---|---|
| 0 | OpenAPI for /v2/products; price as an object, imageUrl mandatory; review with Orders, BFF and partners |
— | 03-06 §10 |
| 1-2 | Serves /v1/ and /v2/ from the same code; the internal model is already the new one and a layer translates back for v1 (price: amount, currency); Deprecation and Sunset (+6 months) on /v1/; pacts: the v1 ones keep verifying |
— | 03-06, 04-05 |
| 2 | MongoDB expand migration: numeric price coexists with detailedPrice until all the code reads the new one (an idempotent script backfills; 05-04 §3 applied to documents) |
— | 05-04 §3 |
| 3-4 | — | Orders: new pact against /v2/products (price.amount, price.currency); productTranslator (the ACL from 04-04) maps {amount, currency} → unitPrice + currency; order_lines.currency already existed since 005-lines-currency-expand.sql and 006-…-contract.sql (05-04); order.created moves to version: 2 with unitPrice: {amount, currency} and an upcaster in the consumers (03-06 §6) to tolerate v1 in the DLQ; BFF: Price in the GraphQL schema, currency @deprecated (03-06) |
Orders canary, can-i-deploy |
| 5-6 | — | Inventory, Payments, Notifications: accept order.created v1 and v2 (eachLike in their message pacts, 08-02 §9); Payments uses the event's currency instead of a fixed 'EUR' |
rolling |
| +6 months | Retires /v1/ (the Sunset header announced it); http_requests_total{route="/v1/products"} metric at 0 for a month beforehand |
None left | 03-06 §7 |
No step cuts anything: at every moment two versions of the contract, of the event and of the schema coexist, and each team deploys when it wants within the deadline. It is the same discipline as in section 6, applied to a change that in the monolith would have been "search for price across the whole repository and cross your fingers on Thursday".
- Quick runbook of frequent operations
| I need to… | Command / resource | Lesson |
|---|---|---|
| See the overall state | kubectl -n techcorp get pods,hpa,scaledobject,externalsecret · "RED per service" dashboard |
05-02, 06-01 |
| Logs of an order across all services | Loki: {namespace="techcorp"} | json | orderId="ord-…" |
06-01 §5 |
| Trace of an order | Jaeger: service=gateway, tag orderId |
06-02 §8 |
| Deploy to production | PR in platform/k8s/<svc>/overlays/prod (newTag); Argo CD syncs |
05-03 §6 |
| Roll back a deployment | argocd app rollback <app> <id> (or revert the commit); emergency: kubectl rollout undo deploy/<svc> |
05-04 §2, 05-03 |
| Cut off a canary | kubectl -n techcorp annotate ingress <svc>-canary nginx.ingress.kubernetes.io/canary-weight=0 --overwrite |
05-04 §5 |
| Change configuration | PR to the overlay's ConfigMap + rollout restart (or a hot flag if there is one) |
04-03, 05-02 §5 |
| Rerun migrations | kubectl -n techcorp delete job <svc>-migrations && kubectl apply -k … (idempotent) |
05-02 §6, 05-03 §5 |
| See queues and DLQ | rabbitmqctl list_queues name messages consumers · console 15672 · "Queues" dashboard |
03-02, 06-03 §8 |
| Reprocess a DLQ | kubectl run … -- node scripts/reprocessDlq.js --queue <queue> --max N [--discard] (audited) |
06-03 §8, 07-03 §9 |
| Orders stuck in the saga | "Orders saga" dashboard; PAYMENT_TIMEOUT watchdog; CronJob reconcile-reservations (kubectl create job --from=cronjob/reconcile-reservations now) |
06-03 §9 |
| Scale by hand (dev) / campaign (prod) | kubectl scale (dev) · prod-bf overlay via PR (prod) |
06-04, §7 |
| Rotate a secret | Vault → annotate externalsecret force-sync → rollout restart |
07-04 §4, §9 |
| Add a service | Copy k8s/orders-service/, change name/port/ConfigMap; its NetworkPolicy in k8s/network/; RabbitMQ user and svc_*; Argo Application |
05-02 §12, 07-04 |
| See the error budget | SLO dashboard; slo:*_budget_remaining:ratio |
06-05 §3, §11 |
| Silence an alert during maintenance | Alertmanager: amtool silence add alertname=… --duration=2h --comment=… |
06-05 §7 |
| Check platform security | SECURITY.md; kubectl -n techcorp get networkpolicy; Trivy in CI; cosign verify |
07-04 |
- Approximate monthly cost and how to reduce it
Fictional, indicative figures for a managed production cluster with 3,000 orders/day (no campaign), rounded up. They serve to give an order of magnitude and for the FinOps argument in 08-04:
| Component | Size | Approx. €/month |
|---|---|---|
| Cluster nodes (managed control plane included) | 6 nodes of 4 vCPU/16 GB + autoscaling to 12 during a campaign | 900 |
| Managed PostgreSQL (5 DBs on 2 instances: orders+payments, the rest) with replica and backups | 2 × (2 vCPU/8 GB) | 420 |
| Managed MongoDB (catalog, 3-member replica set) | M20-equivalent | 180 |
| Managed RabbitMQ (3 nodes, TLS) | small | 160 |
| Managed Redis (catalog cache) | 1 GB with replica | 60 |
| Keycloak (in the cluster) + its PostgreSQL | 2 pods, DB shared with "the rest" | 40 |
| Observability: Prometheus/Grafana/Loki/Jaeger in the cluster + object storage (metrics 15 d, logs 30 d, traces 7 d at 10%) | ~1.5 nodes + 400 GB | 260 |
| Load balancer, IPs, egress traffic, certificates | — | 90 |
| Container registry, Pact Broker, secrets manager, GitHub Actions (extra minutes) | SaaS | 150 |
| Staging (all of the above at 1/3 scale, shut down at night) | — | 500 |
| Total | ≈ €2,760/month (≈ 4,100 in the Black Friday month) |
Compared with the monolith (three large servers + PostgreSQL + ten servers for one month a year ≈ €1,900/month on average), it is 45% more expensive in pure infrastructure… and 30% cheaper during a campaign, and it does not include what is saved in deployment hours and incidents (08-01 §10). How to reduce it, in order of return: (1) real requests measured with Prometheus (06-04 §4) and node right-sizing: most TechCorp services request 250 m of CPU and use 60 m; (2) shut down staging outside working hours (already done) and use KEDA's minReplicaCount: 0 for sporadic consumers such as analytics; (3) trace sampling at 10% and 30-day log retention (already), sample_limit and a quarterly cardinality review (08-01 §11); (4) reserve base node capacity for a year (−30%); (5) review whether Redis, under real load, pays off versus the gateway's HTTP cache; (6) do not save on backups, TLS or replicas of the Orders and Payments databases.
Common Mistakes and Tips
- Installing the infrastructure by hand and the services through GitOps. Three months later nobody knows which RabbitMQ version is running or with which
values. Everything ink8s/infra/and applied by script or by Argo, even if it is Helm. - Skipping the CRD order. An
ExternalSecretapplied before ESO or aServiceMonitorbefore the operator fails with "no matches for kind": the script waits with--waitfor each operator before continuing. - A smoke test without a token or with the admin token. It must use a user with the
customerrole and theircustomerId: it is the only way to check the owner rule and the 404 for someone else's order. - Scaling by hand in production with Argo CD in
selfHeal. It reverts it within minutes and, worse, in the middle of a campaign. Through a PR, always. - Reprocessing the DLQ before understanding the cause. If the message is poison, it goes back to the DLQ and burns five retries; if the dependency is still down, same thing. First Loki/Jaeger, then the script.
- Rotating a secret without
rollout restartand believing it worked becausekubectl get secretshows the new one. The old pods keep the old password until they restart (or until it is read as a file). - Tip: run
deploy-all.sh devon a clean kind cluster once a month. If it takes more than 15 minutes or fails at some step, something in theplatformtree has gone stale; better to find out on a Tuesday than during disaster recovery.
Exercises
Exercise 1: Disaster recovery
The production cluster is lost entirely (region down). With the platform repository, the backups of the managed databases and the images in ghcr.io, write the recovery sequence on a new cluster in another region, indicating which steps from section 3 change, what must be restored before what, what data could be lost (think about the outbox and the queues) and a time estimate. What would need to be prepared in advance for it to take an hour instead of a day?
Exercise 2: The canary that lies
During the 10% canary of orders-service 1.6.0 the burn rate is perfect, but when moving to 100% saga_duration_seconds p95 rises from 3 to 40 s. Explain what kind of failure a canary by HTTP traffic weight does not detect in a service that also consumes events, how you would have detected it during the canary (specific metrics and labels), and what you would change in the procedure from section 6.
Exercise 3: Cutting the bill
Marta asks to bring the monthly bill down 25% without touching the availability of Orders or Payments. Using the table in section 11, propose a concrete list of measures with their estimated savings and their risk, and say which ones you would not accept even if they save money.
Solutions
Exercise 1. Sequence: steps 1-2 unchanged (namespaces, operators) on the new cluster; step 3 changes: restore the managed databases from backup (PostgreSQL for Orders and Payments first, with point-in-time recovery to the closest instant; the catalog MongoDB; RabbitMQ is not restored: it is created empty with definitions.json, and in-flight messages are lost); step 4 Keycloak from its restored DB; 5-6 unchanged; 7 the ExternalSecrets point at the same Vault (which must be outside the region or replicated); 8-9 unchanged, switching the api.techcorp.example DNS to the new load balancer and waiting for the certificate. Data at risk: the events that were in RabbitMQ unconsumed (durable queues of a lost cluster) and the seconds between the last backup and the outage. The outbox is the partial salvation: on startup, the relays of the four services republish everything with published_at IS NULL, so events generated but not published are recovered; those already published and not consumed are lost → orders in STOCK_RESERVED or PENDING that do not advance are closed by the watchdog (PAYMENT_TIMEOUT) and the reservation reconciliation (06-03 §9), and chargeOrder looks up the payment provider before charging (IN_PROGRESS), so there are no double charges. Time: with everything at hand and rehearsed, 2-4 hours; without practice, a day. For one hour: a "warm" secondary cluster with the infrastructure of steps 1-7 already applied and Argo CD pointing at the same repository (only the target changes), replicas of the managed DBs in the other region, multi-region Vault, and a quarterly recovery drill (the tip from the previous section).
Exercise 2. The Ingress weight-based canary splits HTTP requests, but the canary replicas also consume orders.saga on equal terms with the stable ones (competing consumers, 06-04 §9): at 10% of HTTP traffic, the canary may already be processing 33% of the events (1 replica out of 3), and if its consumer is slow (an unindexed query on orders added in 1.6.0), the effect is diluted among the stable ones and shows up "all at once" at 100%. Detection during the canary: look at metrics labeled by version (the service.version label from 06-02, which the common-http metrics include): histogram_quantile(0.95, sum by (le, version) (rate(saga_duration_seconds_bucket[5m]))), the duration of processOnce per version, pg_stat_statements or the latency of the consumer's queries, and x-attempts/un-acked messages on orders.saga per pod. Procedure change: add to section 6 a "canary vs stable" panel that also compares consumer and database metrics by version, not just HTTP RED; and, for services with consumers, lengthen the canary or do a per-replica canary with a test queue (or a mesh, when it arrives) —it is one of the signals 02-05 set for reconsidering saga orchestration and that 05-05 left as an argument in favor of the mesh.
Exercise 3. Measures: (1) real requests and nodes from 6 to 4 outside campaigns (autoscaling continues): −€250, low risk if the PDBs and the HPA are right; (2) staging at 1/4 and only during working hours with on-demand startup: −€200, low risk (the cd.yml E2E takes 5 more minutes if it is off); (3) one-year reserved capacity for 4 base nodes: −€180, commitment risk; (4) traces at 5% with error-tail sampling at 100%, Catalog logs at info (today debug in a forgotten overlay), 15 days of logs: −€80, medium risk for debugging; (5) MongoDB down one instance size with the Redis cache (which already absorbs 85%): −€60, low risk, measure with k6; (6) KEDA to zero for analytics and bff-mobile at night: −€30. Total ≈ −€800 (29%). I would not accept: removing the replica or the backups of the Orders/Payments PostgreSQL, moving RabbitMQ to a single node, removing internal TLS from RabbitMQ/DBs, or reducing Orders/Payments minReplicas to 1 (a single pod breaks the zero-downtime rolling from 05-04 and the PDB): they save €100-200 and put the 99.9% SLO and the security from 07-02 at stake.
Conclusion
TechCorp's system is no longer just code: it lives in a cluster and is operated with written procedures. We have walked through the complete techcorp/platform repository (bases and overlays per service, k8s/network/, k8s/infra/ with the Helm values for RabbitMQ, PostgreSQL, MongoDB, Redis, Keycloak and observability, SLOs, Argo CD, dashboards, runbooks, E2E and load tests, the reusable workflow and SECURITY.md), the compose.yaml that brings up the six services with Keycloak, Jaeger, Prometheus, Grafana and Loki on a laptop, the startup order of a fresh cluster (namespaces → operators and CRDs → data and messaging → identity → observability → network → secrets → services in saga order → Ingress) captured in deploy-all.sh with its idempotent migration Jobs, and the verification with a real smoke test —a Keycloak token, an order through the Ingress that reaches CONFIRMED, its trace in Jaeger, its mark on the "Orders saga" dashboard and its logs in Loki—. And we have operated: an orders-service deployment from PR to 100% through CI, pacts, signed image, staging, can-i-deploy, human review, Argo CD and an observed canary; the Black Friday checklist with campaign overlay, KEDA, error budget and freeze; incident INC-2047 in payments.stock.dlq resolved with an alert, a runbook, Loki, Jaeger, two configuration changes and reprocessDlq.js without a single duplicate charge; the rotation of orders-db with ESO, the Node 22 upgrade propagated through the template and the reusable workflow, and products v2 with price {amount, currency} applied end to end with expand/contract; plus the quick runbook and a monthly bill with its levers.
With this, TechCorp's story is complete: monolith, migration, implementation, deployment and operations. What remains is the most valuable part of a case study, which is distilling it: what we did, what went wrong and what we would do differently in each dimension, which antipatterns we learned to recognize, a consolidated list of best practices with their reference lesson, what TechCorp would do in its phase 2 and how you can apply all of this to your own context. It is the last lesson of the course.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
