Bỏ qua

Football Booking — Infrastructure Architecture

Purpose: This document describes the complete K3s infrastructure architecture of the Football Booking system, covering the role of each file, deployment flow, and how components interconnect. Designed to deploy on 3 local UTM VMs (development) or 3 Oracle Cloud VMs (production-equivalent).


1. Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                         INTERNET / Local Browser                        │
└─────────────────────────────┬───────────────────────────────────────────┘
                              │ HTTP :80 / :443
                              ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  MASTER NODE  (192.168.64.10 / Oracle VM)                               │
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────────┐   │
│  │  nginx-ingress-controller  (K3s, namespace: ingress-nginx)       │   │
│  │  ServiceLB klipper-lb → auto-assigns node IP                     │   │
│  └───────────────────────┬──────────────────────────────────────────┘   │
│                          │  routes /  → api-gateway:3000                │
│  ┌───────────────────────▼──────────────────────────────────────────┐   │
│  │  K3s Pods  (namespace: football-booking)                         │   │
│  │                                                                   │   │
│  │  api-gateway:3000 ──► user-service:3001                         │   │
│  │                   ──► field-service:3002                         │   │
│  │                   ──► booking-service:3003                       │   │
│  │                   ──► payment-service:3004                       │   │
│  │                   ──► notification-service:3005                  │   │
│  │                   ──► chatbot-service:3007                       │   │
│  └───────────────────────┬──────────────────────────────────────────┘   │
│                          │  MASTER_IP:PORT                              │
│  ┌───────────────────────▼──────────────────────────────────────────┐   │
│  │  Docker Compose — Stateful Stack                                 │   │
│  │                                                                   │   │
│  │  postgres:5432   mongodb:27017   redis:6379   kafka:9093         │   │
│  │  keycloak:8080   omni-route:20128                                │   │
│  └──────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────────┐   │
│  │  Docker Compose — Observability Stack                            │   │
│  │                                                                   │   │
│  │  clickhouse:8123/9000   otel-collector:4317/4318                 │   │
│  │  signoz-frontend:3301   langfuse-web:3100                        │   │
│  │  chatbot-redis:6380     langfuse-minio:9090                      │   │
│  └──────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘

WORKER NODE 1  (192.168.64.11)   ──► runs K3s workloads (pods)
WORKER NODE 2  (192.168.64.12)   ──► runs K3s workloads (pods)

Separation of Concerns

Layer Technology Node Reason
Stateful services (DB, cache, queue, auth) Docker Compose Master Need persistent data, not horizontally scalable
Stateless apps (NestJS, FastAPI) K3s + Helm Workers Horizontal scaling, rolling deploy, auto health-check
Observability (SigNoz, LangFuse) Docker Compose Master Separate stack, start/stop independently
CI/CD (GitHub Actions) Managed cloud N/A Stateless reusable workflows, no dedicated server needed

2. Directory Structure

football-booking-infra-k3s/
│
├── ansible.cfg                    # Ansible global config
├── README.md                      # Project overview
├── docs/                          # Architecture & guides (this folder)
│   ├── ARCHITECTURE.md            # This file (English)
│   ├── ARCHITECTURE.vi.md         # Vietnamese version
│   ├── READING_GUIDE.md           # Sequential reading guide (English)
│   └── READING_GUIDE.vi.md        # Vietnamese version
│
├── inventory/                     # Environment definitions
│   ├── utm/                       # Local VMs via UTM (development)
│   │   ├── hosts.ini              # IPs: 192.168.64.10/11/12
│   │   └── group_vars/
│   │       ├── all.yml            # Shared vars: k3s_master_ip, versions, env_name
│   │       ├── k3s_master.yml     # Master-node-specific vars
│   │       └── k3s_workers.yml    # Worker-node-specific vars
│   └── oracle/                    # Oracle Cloud VMs (production-equivalent)
│       ├── hosts.ini              # Oracle instance public IPs
│       └── group_vars/
│           └── all.yml            # Same as utm but env_name: "oracle"
│
├── playbooks/                     # Entry points — run these
│   ├── site.yml                   # Full cluster setup (first run or rebuild)
│   ├── deploy.yml                 # Re-deploy all app services
│   └── deploy_service.yml         # Re-deploy a single service (manual fallback)
│
├── roles/                         # Ansible roles — one per setup step
│   ├── common/                    # OS baseline for all nodes
│   ├── docker/                    # Install Docker CE on master
│   ├── k3s_master/                # Install K3s server on master
│   ├── k3s_worker/                # Join K3s cluster from workers
│   ├── kubeconfig/                # Fetch kubeconfig to local Mac
│   ├── stateful_stack/            # Deploy Docker Compose stateful stack
│   ├── helm_apps/                 # Create K8s Secrets + deploy Helm charts
│   └── ingress/                   # Install nginx-ingress + deploy Ingress resource
│
├── helm/                          # Helm charts for K3s workloads
│   ├── backend/                   # Shared chart for 6 NestJS microservices
│   ├── chatbot/                   # Dedicated chart for FastAPI chatbot
│   └── ingress/                   # Chart defining Ingress routing rules
│
├── docker-compose/                # Docker Compose for stateful services on master
│   ├── stateful-stack.yml         # Core infra: PG, Mongo, Redis, Kafka, Keycloak, OmniRoute
│   ├── observability-stack.yml    # Monitoring: ClickHouse, SigNoz, LangFuse, MinIO
│   ├── config/
│   │   └── otel-collector-config.yaml  # OpenTelemetry Collector pipeline config
│   └── init-scripts/
│       ├── create-multiple-dbs.sh      # Create 7 PG databases + chatbot user on init
│       └── mongo-init.js               # Create notification_db collections on init
│
├── .github/workflows/             # GitHub Actions CI/CD
│   ├── cd-backend.yml             # Reusable: deploy 1 backend service via SSH + helm
│   ├── cd-chatbot.yml             # Reusable: deploy chatbot service
│   ├── _example-caller-backend.yml # Template to copy into backend app repo
│   ├── _example-caller-chatbot.yml # Template to copy into chatbot app repo
│   └── README.md                  # GitHub Actions setup guide
│
├── secrets/
│   ├── vault.yml                  # All secrets (MUST encrypt before commit)
│   └── .gitignore                 # Prevents committing plaintext vault
│
└── scripts/
    ├── check-before-commit.sh     # Security check before git commit
    ├── setup-utm-network.sh       # Configure UTM bridged/shared network
    └── verify-cluster.sh          # Check cluster health after deploy

3. Inventory — Environment Management

inventory/utm/hosts.ini

Defines 3 UTM VMs with static IPs:

[k3s_master]  192.168.64.10
[k3s_workers] 192.168.64.11, 192.168.64.12

SSH key: ~/.ssh/utm_k3s

inventory/oracle/hosts.ini

Same structure but uses Oracle Cloud public IPs and key ~/.ssh/oracle_football_booking.

inventory/utm/group_vars/all.yml — Key Variables

Variable Value Used in
k3s_master_ip 192.168.64.10 Injected into .env → Kafka EXTERNAL, K8s Secrets
k3s_version v1.29.4+k3s1 Install exact K3s version
env_name utm-local Determines values file: utm or oracle
backend_image_prefix sangtrandev00 Docker Hub registry prefix

The env_short mechanism

Roles read env_name to select the correct Helm values file:

env_short: "{{ 'utm' if 'utm' in env_name else 'oracle' }}"
# → "utm"    when using inventory/utm
# → "oracle" when using inventory/oracle

Helm then deploys with: -f values.yaml -f values.{{ env_short }}.yaml


4. Playbooks — Entry Points

playbooks/site.yml — Full cluster setup

Run once when creating new VMs or rebuilding from scratch. Executes 8 sequential steps:

Step 1: common         → all nodes    → OS baseline, swap, firewall, sysctl
Step 2: docker         → master only  → Install Docker CE + Compose plugin
Step 3: k3s_master     → master only  → Install K3s server, disable Traefik
Step 4: k3s_worker     → workers      → Join K3s cluster with node-token
Step 5: kubeconfig     → master→local → Copy ~/.kube/football-k3s.yaml to Mac
Step 6: stateful_stack → master only  → Render .env, copy files, docker compose up
Step 7: helm_apps      → master only  → Create K8s Secrets, deploy Helm charts
Step 8: ingress        → master only  → Install nginx-ingress, deploy Ingress rules
ansible-playbook -i inventory/utm playbooks/site.yml --vault-password-file ~/.vault_pass

playbooks/deploy.yml — Re-deploy all apps

Use when rolling out new images for all services:

ansible-playbook -i inventory/utm playbooks/deploy.yml \
  -e "backend_image_tag=abc1234" -e "chatbot_image_tag=abc1234" \
  --vault-password-file ~/.vault_pass

Only runs the helm_apps role — updates Helm releases with the new tag.

playbooks/deploy_service.yml — Re-deploy a single service

Used for manual deploys or as a GitHub Actions fallback:

ansible-playbook -i inventory/utm playbooks/deploy_service.yml \
  -e "service=user-service" -e "image_tag=abc1234"

Runs helm upgrade --install for exactly one service then verifies the rollout.


5. Roles — Step-by-Step Details

roles/common/ — OS baseline

Runs on: all nodes
What it does: - Update apt, install base packages (curl, wget, ufw, htop) - Configure sysctl for K3s: net.ipv4.ip_forward=1, bridge-nf-call-iptables=1 - Create 2GB swap file (K3s needs RAM headroom) - Open firewall ports: 22 (SSH), 6443 (K3s API), 8472 (Flannel VXLAN), 10250 (Kubelet)

Key file: templates/sysctl-k3s.conf.j2/etc/sysctl.d/99-k3s.conf

roles/docker/ — Docker on master

Runs on: master only
What it does: Install Docker CE + Compose plugin, add user ubuntu to docker group.

roles/k3s_master/ — K3s control plane

Runs on: master only
What it does:

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.29.4+k3s1 sh -s - server \
  --tls-san 192.168.64.10 \
  --disable traefik          # ← Use nginx-ingress instead

Then reads /var/lib/rancher/k3s/server/node-token and sets fact k3s_token for workers.

Why disable Traefik? K3s bundles Traefik by default, but this project uses nginx-ingress for more flexible configuration (proxy-body-size, timeouts, path rewriting).

roles/k3s_worker/ — Join cluster

Runs on: workers
What it does:

curl -sfL https://get.k3s.io | K3S_URL=https://192.168.64.10:6443 \
  K3S_TOKEN={{ k3s_token }} sh -

roles/kubeconfig/ — Fetch kubeconfig

Runs on: master → copies to local Mac
What it does: Copies /etc/rancher/k3s/k3s.yaml to ~/.kube/football-k3s.yaml, replacing 127.0.0.1 with k3s_master_ip for remote access.

roles/stateful_stack/ — Docker Compose infrastructure

Runs on: master only
Execution order (tasks/main.yml): 1. Create directory /home/ubuntu/stacks/football-booking/ 2. Render .env from Ansible Vault → /home/ubuntu/stacks/football-booking/.env 3. Copy stateful-stack.yml, observability-stack.yml 4. Copy init-scripts/ (PostgreSQL + MongoDB init) 5. Copy config/otel-collector-config.yaml 6. docker compose up -d for stateful-stack 7. docker compose up -d for observability-stack

Key files: - templates/env.production.j2 → Jinja2 template renders Vault secrets into the .env file on the server - vars/vault.yml → Local copy of vault vars (used when running this role standalone)

roles/helm_apps/ — Deploy K8s workloads

Runs on: master only
Execution order (tasks/main.yml): 1. Install Helm if not present 2. Create namespace football-booking 3. Copy the entire helm/ directory to /tmp/helm/ 4. Create K8s Secret backend-secrets (30+ env vars for NestJS services) 5. Create K8s Secret chatbot-secrets (LLM keys, Redis, DB for chatbot) 6. Deploy chatbot: helm upgrade --install chatbot-service /tmp/helm/chatbot ... 7. Deploy backend: loop over 6 services with helm upgrade --install

Why --dry-run=client -o yaml | kubectl apply -f -?
To make the operation idempotent — running multiple times won't fail with "already exists". kubectl create secret cannot run twice for the same resource.

roles/ingress/ — Nginx Ingress Controller

Runs on: master only
What it does: 1. Set env_short fact 2. Add Helm repo ingress-nginx 3. helm upgrade --install ingress-nginx with service.type=LoadBalancer 4. K3s ServiceLB (klipper-lb) automatically assigns the node IP to the LoadBalancer service 5. Deploy football-ingress Helm chart → creates Ingress resource routing /api-gateway:3000


6. Docker Compose Stacks

docker-compose/stateful-stack.yml — Core Infrastructure

Service Image Port (host) Role
postgres postgres:16-alpine 5432 7 databases (user, field, booking, payment, keycloak, langfuse, chatbot)
mongodb mongo:7 27017 notification_db for notification-service
redis redis:7-alpine 6379 Cache, rate limiting, sessions (allkeys-lru)
kafka apache/kafka:4.3.1 9093 (EXTERNAL) Event bus between microservices — KRaft mode (no Zookeeper)
keycloak keycloak:24.0.1 8080 OAuth2/OIDC Identity Provider
omni-route diegosouzapw/omniroute 20128 Multi-provider LLM router (Groq, Gemini)

Kafka dual listener — why is it needed?
K3s pods on worker nodes cannot resolve the hostname kafka (Docker DNS only works within the Docker network). Two listeners are required:

PLAINTEXT://kafka:9092       → used by Docker containers internally
EXTERNAL://MASTER_IP:9093    → used by K3s pods connecting via host IP

MASTER_IP is injected from .env (rendered by Ansible from k3s_master_ip).

Port bindings on 0.0.0.0:
All services bind to 0.0.0.0 (not 127.0.0.1) so K3s pods on worker nodes can reach them via MASTER_IP:PORT. In production, restrict access with firewall/VPC rules.

docker-compose/observability-stack.yml — Monitoring & LLM Observability

Service Image Port (host) Role
clickhouse clickhouse:24.8-alpine 8123, 9000 OLAP store for SigNoz traces/metrics + LangFuse analytics
clickhouse-init-databases same - One-time init: creates signoz_metrics, signoz_traces, signoz_logs
otel-collector signoz-otel-collector 4317, 4318 Receives OTLP traces from K3s pods, writes to ClickHouse
query-service signoz/query-service internal SigNoz backend API
signoz-frontend signoz/frontend 3301 SigNoz dashboard UI
chatbot-redis redis:7-alpine 6380 Dedicated session store for chatbot (allkeys-lru)
langfuse-redis redis:7-alpine internal LangFuse ingestion queue (noeviction)
minio cgr.dev/chainguard/minio 9090, 9091 S3 blob store for LangFuse trace attachments
langfuse-web langfuse/langfuse:3 3100 LangFuse UI + API
langfuse-worker langfuse/langfuse-worker:3 internal Async processor for LangFuse traces

Why 3 separate Redis instances? - redis (6379, stateful-stack): Backend services, allkeys-lru eviction - chatbot-redis (6380, observability-stack): Chatbot session store, allkeys-lru - langfuse-redis (internal): LangFuse ingestion queue, must use noeviction to prevent trace data loss

clickhouse-init-databases:
SigNoz requires 3 separate databases. This container runs clickhouse-client --query="CREATE DATABASE IF NOT EXISTS ..." then exits. Both otel-collector and query-service depend on service_completed_successfully.

docker-compose/init-scripts/

create-multiple-dbs.sh — Runs once during PostgreSQL initialization: 1. Reads POSTGRES_MULTIPLE_DATABASES=user_db,field_db,...,chatbot_db 2. Creates each database with psql CREATE DATABASE 3. Creates dedicated user chatbot with password from CHATBOT_DB_PASSWORD 4. Grants CONNECT + schema permissions on chatbot_db to user chatbot

Why a dedicated chatbot user? Least-privilege — chatbot only needs read/write access to chatbot_db, not admin access. Mirrors the production setup.

mongo-init.js — Runs once during MongoDB initialization:
Creates notification_db, collections notifications + email_logs, and required indexes.


7. Helm Charts — K3s Workloads

helm/backend/ — NestJS Microservices

Shared across 6 services: api-gateway, user-service, field-service, booking-service, payment-service, notification-service.

Chart.yaml              → metadata: name=backend, version=0.1.0
values.yaml             → defaults: replicaCount=1, image, resources, HPA disabled
values.utm.yaml         → UTM overrides (lower resource limits)
values.oracle.yaml      → Oracle overrides (higher limits, HPA enabled)
templates/
  deployment.yaml       → K8s Deployment, envFrom: backend-secrets
  service.yaml          → ClusterIP service
  hpa.yaml              → HorizontalPodAutoscaler (optional)
  _helpers.tpl          → Template helpers: fullname, labels

Each service deploys as a separate Helm release:

helm upgrade --install user-service /tmp/helm/backend \
  --set image.repository=sangtrandev00/user-service \
  --set image.tag=abc1234

Env vars: All injected via envFrom: [{secretRef: {name: backend-secrets}}].
No hard-coded env vars in pods — everything comes from the backend-secrets K8s Secret.

helm/chatbot/ — FastAPI Chatbot

Similar to backend but: - Port: 3007 (service) → 3006 (container) - HPA: enabled by default, scales 1→5 replicas at 70% CPU - Secret: chatbot-secrets (LLM keys, Pinecone, Redis 6380, LangFuse)

helm/ingress/ — Routing Rules

values.yaml        → host: "" (IP-based), apiGateway.service=api-gateway, port=3000
values.utm.yaml    → host: "" (direct IP access)
values.oracle.yaml → host: "" (can set a domain if available)
templates/
  ingress.yaml     → Ingress resource with ingressClassName: nginx

Traffic flow after deploy:

Request → nginx-ingress-controller → api-gateway:3000 → (K8s internal DNS) → services

8. Secrets Management

Two vault files structure

secrets/vault.yml                    ← MAIN file, loaded by site.yml/deploy.yml
roles/stateful_stack/vars/vault.yml  ← Subset copy, loaded when running this role standalone

Both files must have the same set of variables. When adding a new secret, update both.

secrets/vault.yml — All secrets

Group Variables
PostgreSQL vault_db_admin_password
Redis main vault_redis_password
Chatbot Redis vault_chatbot_redis_password
Chatbot DB user vault_chatbot_db_password
Kafka UI vault_kafka_ui_password
ClickHouse vault_clickhouse_password
Keycloak vault_keycloak_admin_password, vault_keycloak_client_secret
Backend secrets vault_internal_service_secret, vault_jwt_secret, vault_jwt_encryption_key
MinIO vault_minio_root_user, vault_minio_root_password
LangFuse vault_langfuse_nextauth_secret, vault_langfuse_salt, vault_langfuse_encryption_key, vault_langfuse_redis_auth, vault_langfuse_init_email, vault_langfuse_password, vault_langfuse_public_key, vault_langfuse_secret_key
LLM vault_groq_api_key, vault_pinecone_api_key, vault_pinecone_index_name
GitHub Actions vault_gh_deploy_key_comment

Secrets flow to server

secrets/vault.yml (encrypted)
        │  ansible-vault decrypt (at runtime)
        ▼
roles/stateful_stack/templates/env.production.j2
        │  Ansible template render (Jinja2)
        ▼
/home/ubuntu/stacks/football-booking/.env  (mode 0600, on server)
        │  Docker Compose ${VAR} expansion
        ▼
Environment variables inside containers

secrets/vault.yml (encrypted)
        │  ansible-vault decrypt (at runtime)
        ▼
roles/helm_apps/tasks/main.yml
        │  kubectl create secret --from-literal=KEY=VALUE
        ▼
K8s Secret: backend-secrets  (namespace: football-booking)
K8s Secret: chatbot-secrets  (namespace: football-booking)
        │  envFrom: secretRef
        ▼
Environment variables inside K3s pods

Ansible Vault commands

# Encrypt before committing
ansible-vault encrypt secrets/vault.yml

# Edit encrypted file
ansible-vault edit secrets/vault.yml

# Run playbook with vault
ansible-playbook -i inventory/utm playbooks/site.yml \
  --vault-password-file ~/.vault_pass

9. K8s Secrets — Connection Strings

backend-secrets — for 6 NestJS microservices

Key Example value Used by
DB_HOST 192.168.64.10 All services
DATABASE_URL_USER postgresql://football_admin:pw@MASTER_IP:5432/user_db user-service
MONGODB_URI mongodb://football_admin:pw@MASTER_IP:27017/notification_db notification-service
REDIS_URL redis://:pw@MASTER_IP:6379 All services
KAFKA_BROKERS MASTER_IP:9093 field, booking, payment, notification
KEYCLOAK_URL http://MASTER_IP:8080 api-gateway, user-service
USER_SERVICE_URL http://user-service:3001 api-gateway (K8s DNS)
OTEL_EXPORTER_OTLP_ENDPOINT http://MASTER_IP:4318 All services
INTERNAL_SERVICE_SECRET random 32-char Header X-Internal-Service

chatbot-secrets — for FastAPI chatbot

Key Example value Note
LLM_PROVIDERS omniroute Uses OmniRoute router
OMNIROUTE_BASE_URL http://MASTER_IP:20128/v1 API keys configured via OmniRoute dashboard
GROQ_API_KEY gsk_... Fallback / add to OmniRoute
REDIS_URL redis://:pw@MASTER_IP:6380 Dedicated chatbot-redis (port 6380)
CHATBOT_DB_URL postgresql://chatbot:pw@MASTER_IP:5432/chatbot_db Dedicated chatbot user
LANGFUSE_HOST http://MASTER_IP:3100 LLM observability
LANGFUSE_PUBLIC_KEY pk-lf-football-prod Must match LANGFUSE_INIT_PROJECT_PUBLIC_KEY
PINECONE_API_KEY pcsk_... Vector DB for RAG

10. Network Communication

┌─────────────────────────────────────────────────────────┐
│  football-booking-network  (Docker bridge)              │
│                                                          │
│  postgres   mongodb   redis   kafka   keycloak          │
│  omni-route   clickhouse   otel-collector               │
│  langfuse-web   langfuse-worker   minio                 │
│  chatbot-redis   langfuse-redis                         │
│  signoz-frontend   query-service                        │
└──────────────────────────┬──────────────────────────────┘
                           │ All containers in the same
                           │ Docker network use hostnames
                           │ (postgres:5432, kafka:9092, ...)
                           │
┌──────────────────────────▼──────────────────────────────┐
│  K3s Pods (worker nodes)                                │
│                                                          │
│  Connect via MASTER_IP:PORT (host binding 0.0.0.0)     │
│  MASTER_IP:5432    → postgres                           │
│  MASTER_IP:27017   → mongodb                            │
│  MASTER_IP:6379    → redis                              │
│  MASTER_IP:6380    → chatbot-redis                      │
│  MASTER_IP:9093    → kafka EXTERNAL listener            │
│  MASTER_IP:8080    → keycloak                           │
│  MASTER_IP:4318    → otel-collector HTTP                │
│  MASTER_IP:3100    → langfuse-web                       │
│  MASTER_IP:20128   → omni-route LLM router              │
│                                                          │
│  K8s internal DNS (ClusterIP):                          │
│  api-gateway → user-service:3001 (K8s DNS)              │
│  api-gateway → chatbot-service:3007 (K8s DNS)           │
└─────────────────────────────────────────────────────────┘

11. OmniRoute — LLM Router

Role: Multi-provider LLM router. The chatbot does not call Groq/Gemini directly — it calls OmniRoute, which routes to the appropriate provider.

chatbot K3s pod
  │  POST http://MASTER_IP:20128/v1/chat/completions
  │  model: "football-fast" or "football-power"
  ▼
omni-route:20128 (Docker Compose, master node)
  │  Routes based on configured model aliases
  ├─► Groq     (llama-3.1-8b-instant)
  ├─► Gemini   (gemini-2.0-flash)
  └─► Anthropic (claude-haiku-4-5)

API keys are added via the OmniRoute dashboard (http://MASTER_IP:20128), not hard-coded in env. Stored in the omniroute_data volume.


12. CI/CD Flow (GitHub Actions)

Developer pushes → GitHub main branch (app repo)
        │
        ▼
GitHub Actions — CI job (runs on GitHub cloud)
  │  dorny/paths-filter → detect which service changed
  │  docker/build-push-action → build image
  │  Push sangtrandev00/<service>:<git-sha> → Docker Hub
        │
        ▼
GitHub Actions — CD job (calls reusable workflow from this infra repo)
  uses: .../football-booking-infra-k3s/.github/workflows/cd-backend.yml@main
        │
        ▼
appleboy/ssh-action → SSH into master node
  helm upgrade --install <service> /tmp/helm/backend \
    --set image.tag=<git-sha> --atomic
        │
        ▼
K3s rolling update → zero downtime
  old pod running → new pod starts → health check passes → old pod terminates
  (--atomic: auto-rollback if health check fails)

Reusable workflows live in this repo: - .github/workflows/cd-backend.yml — deploys 1 backend service - .github/workflows/cd-chatbot.yml — deploys chatbot service

App repos do not contain deploy logic — they only call workflows from this repo, keeping all deploy logic centralized.

Manual deploy (fallback without GitHub Actions):

ansible-playbook -i inventory/oracle playbooks/deploy_service.yml \
  -e "service=user-service" -e "image_tag=abc1234"

GitHub Secrets required in each app repo: - DEPLOY_SSH_KEY — SSH private key for master node - K3S_MASTER_HOST — master node IP - DOCKER_HUB_USERNAME + DOCKER_HUB_TOKEN

Full setup guide: .github/workflows/README.md


13. First Deploy Guide (UTM)

Step 1: Preparation

# Create 3 UTM VMs (Ubuntu 22.04), configure static IPs:
# Master: 192.168.64.10
# Worker1: 192.168.64.11
# Worker2: 192.168.64.12

# Create SSH key
ssh-keygen -t ed25519 -f ~/.ssh/utm_k3s

# Copy public key to each VM
ssh-copy-id -i ~/.ssh/utm_k3s.pub ubuntu@192.168.64.10
ssh-copy-id -i ~/.ssh/utm_k3s.pub ubuntu@192.168.64.11
ssh-copy-id -i ~/.ssh/utm_k3s.pub ubuntu@192.168.64.12

Step 2: Fill in secrets

# Edit vault.yml, replace all "CHANGE_ME" with real values

# Generate required random values:
openssl rand -hex 32    # → LANGFUSE_ENCRYPTION_KEY (must be 64 hex chars)
openssl rand -base64 32 # → JWT_SECRET, NEXTAUTH_SECRET, etc.

# Encrypt
ansible-vault encrypt secrets/vault.yml
ansible-vault encrypt roles/stateful_stack/vars/vault.yml
echo "my-vault-password" > ~/.vault_pass
chmod 600 ~/.vault_pass

Step 3: Deploy

# Full cluster setup (~15-20 minutes)
ansible-playbook -i inventory/utm playbooks/site.yml \
  --vault-password-file ~/.vault_pass

# Verify
./scripts/verify-cluster.sh utm
kubectl --kubeconfig ~/.kube/football-k3s.yaml get pods -n football-booking

Step 4: Configure OmniRoute

# Open dashboard
open http://192.168.64.10:20128

# Add API keys via the UI:
# - Groq: gsk_xxx → alias "football-fast" (llama-3.1-8b-instant)
# - Gemini: AIzaxxx → alias "football-power"

Step 5: Verify services

# Test API Gateway
curl http://192.168.64.10/health

# LangFuse UI
open http://192.168.64.10:3100

# SigNoz APM
open http://192.168.64.10:3301

# Keycloak Admin
open http://192.168.64.10:8080

14. Day-to-Day Operations

# Re-deploy after code push
ansible-playbook -i inventory/utm playbooks/deploy.yml \
  -e "backend_image_tag=$(git rev-parse --short HEAD)"

# Deploy a specific service
ansible-playbook -i inventory/utm playbooks/deploy_service.yml \
  -e "service=user-service" -e "image_tag=abc1234"

# Stream K3s pod logs
kubectl --kubeconfig ~/.kube/football-k3s.yaml logs \
  -n football-booking deployment/user-service -f

# Stream Docker Compose logs on master
ssh ubuntu@192.168.64.10 \
  "cd /home/ubuntu/stacks/football-booking && docker compose -f stateful-stack.yml logs kafka -f"

# Scale chatbot
kubectl --kubeconfig ~/.kube/football-k3s.yaml scale \
  deployment/chatbot-service --replicas=3 -n football-booking

# Restart a service
kubectl --kubeconfig ~/.kube/football-k3s.yaml rollout restart \
  deployment/api-gateway -n football-booking

15. Quick Port Reference

Port Service Accessible from
80/443 nginx-ingress Internet / Browser
3000 api-gateway (K8s ClusterIP) Internal K8s only
3100 langfuse-web Browser
3301 signoz-frontend Browser
4317 otel-collector gRPC K3s pods
4318 otel-collector HTTP K3s pods
5432 PostgreSQL K3s pods, local dev
6379 Redis (backend) K3s pods
6380 chatbot-redis K3s pods (chatbot only)
8080 Keycloak K3s pods, browser
9093 Kafka EXTERNAL K3s pods
20128 OmniRoute LLM router Browser, K3s pods
27017 MongoDB K3s pods