Personal SRE learning lab built on AlmaLinux with KVM virtualization. Every script, incident report, and config committed here is from real hands-on work — not tutorials, not copy-paste. Built, broken, and fixed by hand.
| URL | Purpose |
|---|---|
| anandar.dev | Node.js app — live with SSL |
| grafana.anandar.dev | Grafana monitoring dashboard — live with SSL |
| git.anandar.dev | Self-hosted Gitea — live with SSL |
All services exposed publicly via Cloudflare Tunnel — no port forwarding, no VPS, no public IP.
Host: sre-lab (AlmaLinux 9.7) — 16GB RAM, 80GB /var
│
├── sre-vm1 (192.168.122.152) — Nginx + Node.js + Gitea + cloudflared + Node Exporter
├── sre-db (192.168.122.127) — MySQL 8.0 + Node Exporter
├── sre-mon (192.168.122.218) — Prometheus + Grafana + Node Exporter
├── sre-fail (192.168.122.224) — Failure simulations + Node Exporter
└── sre-container (192.168.122.73) — Podman containers (rootless quadlets)
Hypervisor stack: KVM → QEMU → libvirt → virsh
- KVM virtualization — 5 VMs managed via virsh, autostart configured
- 3-tier app stack — Nginx reverse proxy → Node.js → MySQL on separate DB node
- CI/CD pipeline — GitHub Actions, self-hosted runner on sre-vm1, push → test → deploy → systemctl restart end-to-end
- Ansible IaC — roles, vault, handlers, templates, Jinja2 across 4 nodes
- Container workloads — sreapp containerized via Podman, rootless quadlets, auto-starts at boot without login
- Self-hosted monitoring — Prometheus scraping all 5 targets, Grafana dashboards live
- Cloudflare Tunnel — public HTTPS with SSL, zero port forwarding
- Self-hosted Git — Gitea v1.25.5 running behind Nginx, backed by MySQL
- Automated backups — mysqldump cron job on sre-db, daily at 2am, 7-day retention
- Health check automation — multi-VM bash script via cron every 15 minutes
- User management — sradmin (full sudo), jradmin (limited systemctl), dev_user (no sudo)
- Failure simulations — disk full, service crash, network interface failure — all documented as incident reports
- SELinux enforcing — not disabled. Configured correctly throughout.
- AWS — custom VPC, subnet, IGW, route table, EC2, IAM role, S3 — built from scratch on own account
- Docker Compose — separate repo (container-lab) — custom Dockerfile, healthcheck-gated service dependencies, env_file secrets management
Stack: GitHub Actions + self-hosted runner on sre-vm1
Flow: git push → checkout → npm install → npm test → deploy → systemctl restart sreapp
Runner: Installed as systemd service, survives reboot, polls GitHub outbound — no inbound ports required.
Secrets: DB credentials stored in GitHub Secrets, injected via ${{ secrets.DB_PASSWORD }} into .env at deploy time. Never hardcoded. Never committed.
SELinux: .env file relabelled etc_t at deploy so systemd can read it — baked into the deploy step, not a manual fix.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm install
- name: Test code
run: npm test
- name: Deploy
run: |
cp -r . /home/anand/sreapp/
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" > /home/anand/sreapp/.env
chcon -t etc_t /home/anand/sreapp/.env
sudo systemctl restart sreappStructure: Full roles layout — defaults/, vars/, tasks/, templates/, handlers/, files/
Vault: AES256 encryption via ansible-vault. Secrets stored in group_vars/all/vault.yml. Never in plaintext.
Playbooks:
| Playbook | Purpose |
|---|---|
ansible/first_playbook.yml |
Package installation and file creation across VMs |
ansible/node_exporter.yml |
Node Exporter deployment — download, extract, systemd, firewall |
ansible/prometheus_config.yml |
Prometheus configuration management via Jinja2 template |
ansible/deploy_node_exporter.yml |
Role-based deployment — 5 lines, full Node Exporter stack |
ansible/create_mysql_user.yml |
MySQL user creation via vault-encrypted credentials |
Key patterns:
- Idempotent playbook design — safe to run multiple times
- Variables for version control —
ne_versiondefined once, used everywhere - Jinja2 templates —
prometheus.yml.j2with dynamic target loops - Handlers — config changes trigger service restarts only when state actually changes
- Vault — credentials never in plaintext,
--ask-vault-passalways required
Account: ap-south-1 (Mumbai) — personal account, built from scratch
sre-vpc (10.0.0.0/16)
└── sre-public-subnet (10.0.1.0/24) — ap-south-1a
├── sre-rtb: 10.0.0.0/16 → local, 0.0.0.0/0 → sre-igw
└── sre-ec2 (t3.micro, Amazon Linux 2023)
└── IAM role: sre-ec2-s3-role (AmazonS3FullAccess)
S3: sre-lab-anand-01072003 — SSE-S3 encryption, public access blocked
What was built hands-on (not console wizard defaults):
- Custom VPC with manually defined CIDR block
- Public subnet tied to a specific AZ
- Internet Gateway created and attached to VPC separately
- Route table with explicit
0.0.0.0/0 → IGWentry, manually associated with subnet - EC2 launched via CLI with security group restricting SSH to my IP only
- IMDSv2 enforced — metadata access requires token-based PUT/GET, not plain curl
- IAM role attached to EC2 — no
aws configure, no static keys, temporary credentials via metadata service - S3 bucket upload/download round-trip verified via
aws s3 cpusing role credentials
| Repo | Purpose |
|---|---|
| container-lab | Docker Compose — custom Dockerfile, healthchecks, service dependencies, env-based secrets |
| Script | Purpose |
|---|---|
scripts/check_services.sh |
Service status check with timestamps and tee logging |
scripts/check_mysql.sh |
MySQL health check via systemctl with logging |
scripts/check_sreapp.sh |
Full stack health check — Nginx, Node.js, MySQL via HTTP |
scripts/multi_health_check.sh |
Multi-VM SSH health check — 5 services, color-coded output, cron every 15m |
scripts/mysql_backup.sh |
Automated mysqldump backup — timestamped, 7-day cleanup, credentials in .my.cnf |
scripts/user_add.sh |
User creation with validation, SSH key setup, sudoers config |
scripts/process_check.sh |
Process validation with pgrep, ps aux output, exit code handling |
scripts/disk_alert.sh |
Disk usage monitoring with threshold alerts, cron every 15m |
scripts/port_scan.sh |
Network port scanner using netcat, 6 common ports, timestamped logging |
scripts/selinux_proof.sh |
SELinux context verification, service status, HTTP health check — enforcing mode proof |
Prometheus (sre-mon:9090) scraping 5 targets:
├── localhost:9090 (Prometheus self-monitoring)
├── 192.168.122.152:9100 (sre-vm1 Node Exporter)
├── 192.168.122.127:9100 (sre-db Node Exporter)
├── 192.168.122.224:9100 (sre-fail Node Exporter)
└── 192.168.122.218:9100 (sre-mon Node Exporter)
↓
Grafana (sre-mon:3000) — Node Exporter Full dashboard (ID 1860)
- All 5 targets healthy and reporting
- Scrape interval: 15 seconds
- Node Exporter deployed and configured via Ansible across all nodes
Runtime: Podman v5.6.0 — rootless, no daemon
sreapp container:
- Custom image built from
Containerfile— Node 18 Alpine + app code - Managed as a systemd quadlet —
~/.config/containers/systemd/sreapp.container - Connects to MySQL on sre-db
- Restart policy:
on-failurewithRestartSec=5— handles DB boot race condition - Lingering enabled — auto-starts at boot without login
Nginx container:
- Rootless quadlet on port 8080
- Auto-starts at boot
| Incident | Summary |
|---|---|
| incident-001 | Gitea 502 — missing /home/git directory caused permission failure on startup |
| incident-002 | Disk full simulation — fallocate test, df/du diagnosis |
| incident-003 | rsyslog crash — missing config file |
| incident-004 | Disk control drill — full df/du/fallocate/rm diagnostic cycle |
| incident-005 | Network interface failure — enp1s0 down on sre-fail, diagnosed and recovered via virsh console |
| incident-006 | sreapp down 4 days — boot race condition on startup |
| incident-007 | Docker port 3000 bind conflict — delayed kernel port release |
| # | Simulation | Method | Outcome |
|---|---|---|---|
| 1 | Disk full | fallocate to fill /var | Diagnosed via df/du, recovered via rm |
| 2 | Service crash | rsyslog config removed | Diagnosed via journalctl, config restored |
| 3 | Disk control drill | fallocate + full diagnostic cycle | Full df/du/lsof/rm workflow under pressure |
| 4 | Network interface failure | ip link set enp1s0 down on sre-fail | SSH lost, recovered via virsh console out-of-band |
- Linux system administration (AlmaLinux / RHEL)
- KVM virtualization and VM lifecycle management
- CI/CD pipeline — GitHub Actions, self-hosted runner, secrets management
- Ansible — roles, vault, handlers, templates, Jinja2, idempotent design
- Container orchestration — Podman, rootless quadlets, systemd integration
- Bash scripting — functions, cron, logging, SSH automation, exit code handling
- Docker Compose — multi-container orchestration, healthchecks,
depends_on: condition, build vs image-pull tradeoffs - Nginx reverse proxy configuration
- MySQL administration and automated backups
- Prometheus + Grafana monitoring stack
- SELinux — enforcing mode, context management, MAC vs DAC
- systemd service management and troubleshooting
- AWS — custom VPC networking, EC2, IAM roles, S3, IMDSv2, zero hardcoded credentials
- Git — feature branches, pull request workflow, merge conflict resolution (single and multi-hunk)
- Incident debugging and documentation
- Network troubleshooting — netcat, tcpdump, firewalld, virsh console recovery
| Folder | Purpose |
|---|---|
scripts/ |
Bash automation scripts |
ansible/ |
Playbooks, roles, inventory, vault |
incident-notes/ |
Incident reports from real failures |
monitoring/ |
Prometheus configs, Grafana dashboards, cloudflared config |
.github/workflows/ |
GitHub Actions CI/CD pipeline |
sreapp/ |
Node.js app with Containerfile |
Becoming a production-grade SRE through deliberate practice — building, breaking, and monitoring real systems on real infrastructure.
"How would you know if this broke at 3am?"